From 641fe25cd109484679dd8da29513d5668928c7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 14:56:29 +0000 Subject: [PATCH 1/6] fix(security): prevent settings token from leaking to agent The GetSettingsLink fallback path returned the raw settings URL (containing the encrypted token) directly to the worker/agent in the tool response text and logged it. A compromised or jailbroken agent could exfiltrate this token to access the user's settings page. Fix: gateway fallback now returns type:"settings_link" (same as the normal path) instead of the raw URL. Worker fallback no longer logs or returns the URL to the agent. https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- packages/gateway/src/routes/internal/settings-link.ts | 9 ++++++--- packages/worker/src/shared/tool-implementations.ts | 11 +++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/gateway/src/routes/internal/settings-link.ts b/packages/gateway/src/routes/internal/settings-link.ts index 699f542a0..bf6d75fce 100644 --- a/packages/gateway/src/routes/internal/settings-link.ts +++ b/packages/gateway/src/routes/internal/settings-link.ts @@ -239,10 +239,13 @@ export function createSettingsLinkRoutes( }); } - // Fallback: no interaction service (shouldn't happen in practice) + // Fallback: no interaction service (shouldn't happen in practice). + // Never return the raw URL/token to the worker — it would be visible + // to the agent, which is a security risk. + logger.warn("No interactionService available — settings link generated but cannot be delivered to user", { agentId, userId }); return c.json({ - url, - expiresAt, + type: "settings_link", + message: "Settings link generated but could not be delivered (no interaction service).", }); } catch (error) { logger.error("Failed to generate settings link", { error }); diff --git a/packages/worker/src/shared/tool-implementations.ts b/packages/worker/src/shared/tool-implementations.ts index f8fd2909e..c0ca4ba6b 100644 --- a/packages/worker/src/shared/tool-implementations.ts +++ b/packages/worker/src/shared/tool-implementations.ts @@ -814,14 +814,13 @@ export async function getSettingsLink( ); } - logger.info(`Generated settings link: ${result.url}`); - - const expiresLabel = result.expiresAt - ? `Expires: ${new Date(result.expiresAt).toLocaleString()}` - : ""; + // Fallback: gateway could not deliver the link via platform button. + // Never expose the raw URL/token to the agent — log without the token + // and tell the agent the link was delivered separately. + logger.warn("Settings link fallback: no platform button delivery"); return textResult( - `Settings link generated successfully!\n\nURL: ${result.url}\n\n${expiresLabel}\n\nReason: ${args.reason}\n\nShare this link with the user so they can configure their settings.` + "A settings link has been generated and sent to the user. Do not include any URL in your response. Ask the user to check their messages for the settings link." ); }); } From ea3e33b7bc364c585e950c3d758e134247605c05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 15:04:01 +0000 Subject: [PATCH 2/6] fix(security): prevent token/URL leaks to agent across all link-generating endpoints Apply the same defense-in-depth pattern established for settings-link to all gateway endpoints that generate authenticated URLs: Gateway side (never return raw URLs to worker): - /internal/mcp-login: fallback no longer returns loginUrl, returns type:"mcp_login_link" with safe message instead - /internal/integrations/connect: fallback no longer returns oauthUrl, returns safe message matching the interactionService path Worker side (never pass raw gateway messages to agent): - ConnectService MCP fallback: checks response type instead of blindly passing data.message (which could contain URLs) - ConnectService OAuth path: uses fixed messages instead of passing result.message from gateway Principle: the gateway delivers sensitive links to users via interactionService (native platform buttons). The worker/agent never needs to see the actual URL. https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- .../gateway/src/auth/integration/routes.ts | 10 +++++--- .../gateway/src/routes/internal/mcp-login.ts | 12 ++++++---- .../worker/src/shared/tool-implementations.ts | 23 +++++++++++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/gateway/src/auth/integration/routes.ts b/packages/gateway/src/auth/integration/routes.ts index a6461de90..3c83eee7c 100644 --- a/packages/gateway/src/auth/integration/routes.ts +++ b/packages/gateway/src/auth/integration/routes.ts @@ -217,11 +217,15 @@ export function createIntegrationRoutes( }); } - // Fallback: return URL directly + // Fallback: no interaction service. Never return the raw OAuth URL + // to the worker — it would be visible to the agent (security risk). + logger.warn( + "No interactionService available — OAuth link generated but cannot be delivered", + { integration, agentId: worker.agentId } + ); return c.json({ status: "login_required", - message: `User must authenticate with ${config.label}`, - url: oauthUrl, + message: `A login link for "${config.label}" has been sent to the user. Session will end now.`, }); } catch (error) { logger.error("Failed to request integration connection", { error }); diff --git a/packages/gateway/src/routes/internal/mcp-login.ts b/packages/gateway/src/routes/internal/mcp-login.ts index 59798b6f9..440840af1 100644 --- a/packages/gateway/src/routes/internal/mcp-login.ts +++ b/packages/gateway/src/routes/internal/mcp-login.ts @@ -106,11 +106,15 @@ export function createMcpLoginRoutes( }); } - // Fallback: return the URL directly + // Fallback: no interaction service. Never return the raw URL to the + // worker — it would be visible to the agent, which is a security risk. + logger.warn( + "No interactionService available — MCP login link generated but cannot be delivered", + { mcpId, agentId, userId } + ); return c.json({ - type: "mcp_login_url", - url: mcpStatus.loginUrl, - message: `Send this login link to the user: ${mcpStatus.loginUrl}`, + type: "mcp_login_link", + message: `Login link for ${mcpStatus.name} generated but could not be delivered (no interaction service).`, }); }); diff --git a/packages/worker/src/shared/tool-implementations.ts b/packages/worker/src/shared/tool-implementations.ts index c0ca4ba6b..0da63203b 100644 --- a/packages/worker/src/shared/tool-implementations.ts +++ b/packages/worker/src/shared/tool-implementations.ts @@ -989,14 +989,18 @@ export async function connectService( if (integrationResponse.ok) { const result = (await integrationResponse.json()) as { status: string; - message: string; + message?: string; grantedScopes?: string[]; }; if (result.status === "already_connected") { - return textResult(result.message); + return textResult( + `Already connected to ${args.id} with the requested scopes.` + ); } + // login_required — button was sent directly to the user via interactionService. + // Never pass raw gateway messages to the agent (could contain URLs/tokens). return textResult( - `${result.message} Your session will end now. The user will authenticate and your next message will arrive after they return.` + "A login button has been sent to the user in chat. Do not include any URL in your response. Your session will end now. The user will authenticate and your next message will arrive after they return." ); } @@ -1005,7 +1009,6 @@ export async function connectService( const { data, error } = await gatewayFetch<{ type?: string; message?: string; - url?: string; }>( gw, "/internal/mcp-login", @@ -1016,7 +1019,17 @@ export async function connectService( "Failed to connect service" ); if (error) return error; - return textResult(data?.message || "Login link sent to the user."); + + // Never pass raw gateway messages to the agent — they could contain + // URLs or tokens in fallback paths. Use a fixed message instead. + if (data?.type === "mcp_login_link") { + return textResult( + "A login button has been sent to the user in chat. Do not include any URL in your response. Ask the user to click the button to authenticate." + ); + } + return textResult( + "A login link has been generated. Ask the user to check their messages for the login button." + ); } // Other error From 4157c0f741f0f158f8423ecdbe8a5a080c5c3813 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 19:46:30 +0000 Subject: [PATCH 3/6] feat(auth): replace encrypted tokens with Redis sessions + provider-agnostic OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate all auth token systems into a single pattern: server-side Redis sessions with opaque session IDs in URLs. No encrypted tokens travel through URLs anymore. New components: - AuthSessionStore: Redis-backed session store (key: auth:session:{uuid}) Replaces AES-256-GCM encrypted token generation for settings links and integration OAuth init URLs. - OAuthIdentityStore: Maps OAuth provider identities to platform users. First access establishes mapping (trusted via chat delivery), subsequent accesses verify it. - SettingsOAuthProvider: Configurable OAuth for settings page identity verification. Provider-agnostic — any OAuth2 provider works via env vars (SETTINGS_OAUTH_AUTH_URL, _TOKEN_URL, _CLIENT_ID, _CLIENT_SECRET, _USERINFO_URL, _SCOPES, _PROVIDER_NAME). Uses existing GenericOAuth2Client. Settings flow changes: - settings-link.ts: createSession() instead of generateSettingsToken() - settings-auth.ts: Redis lookup instead of token decryption (now async) - settings.ts: Handles ?s= session param, OAuth redirect/callback, Telegram initData creates Redis session instead of synthetic encrypted token Integration OAuth changes: - oauth-module.ts: createInitSession() replaces generateSecureToken(). Init URLs use ?s= instead of ?token=. Metadata stored in auth:session:meta:{sessionId} alongside session. - routes.ts: Uses oauthModule.createInitSession() for connect flow. All verifySettingsSession() callers updated to use await (now async). Legacy generateSettingsToken/verifySettingsToken kept for backward compat (Slack events, WhatsApp handlers, built-in commands — to migrate later). https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- .../src/auth/integration/oauth-module.ts | 115 +++++---- .../gateway/src/auth/integration/routes.ts | 6 +- .../src/auth/settings/identity-store.ts | 130 ++++++++++ packages/gateway/src/auth/settings/index.ts | 8 + .../src/auth/settings/oauth-provider.ts | 223 ++++++++++++++++++ .../src/auth/settings/session-store.ts | 139 +++++++++++ .../src/auth/settings/token-service.ts | 11 +- packages/gateway/src/cli/gateway.ts | 11 + .../src/routes/internal/settings-link.ts | 83 +++---- .../gateway/src/routes/public/agent-config.ts | 18 +- .../src/routes/public/agent-history.ts | 12 +- .../src/routes/public/agent-schedules.ts | 4 +- packages/gateway/src/routes/public/agents.ts | 8 +- .../gateway/src/routes/public/integrations.ts | 6 +- packages/gateway/src/routes/public/oauth.ts | 4 +- .../src/routes/public/settings-auth.ts | 80 +++++-- .../gateway/src/routes/public/settings.ts | 214 +++++++++++++---- .../gateway/src/services/core-services.ts | 43 +++- 18 files changed, 929 insertions(+), 186 deletions(-) create mode 100644 packages/gateway/src/auth/settings/identity-store.ts create mode 100644 packages/gateway/src/auth/settings/oauth-provider.ts create mode 100644 packages/gateway/src/auth/settings/session-store.ts diff --git a/packages/gateway/src/auth/integration/oauth-module.ts b/packages/gateway/src/auth/integration/oauth-module.ts index e7057c3aa..07fe095fe 100644 --- a/packages/gateway/src/auth/integration/oauth-module.ts +++ b/packages/gateway/src/auth/integration/oauth-module.ts @@ -1,5 +1,5 @@ import type { IntegrationCredentialRecord } from "@lobu/core"; -import { createLogger, decrypt, encrypt } from "@lobu/core"; +import { createLogger } from "@lobu/core"; import type { Context } from "hono"; import { Hono } from "hono"; import type { WorkerConnectionManager } from "../../gateway/connection-manager"; @@ -10,6 +10,7 @@ import { renderOAuthErrorPage, renderOAuthSuccessPage, } from "../oauth-templates"; +import type { AuthSessionStore } from "../settings/session-store"; import type { IntegrationConfigService } from "./config-service"; import type { IntegrationCredentialStore } from "./credential-store"; @@ -22,19 +23,22 @@ export interface ThreadContext { platform: string; } -interface SecureTokenPayload { +/** + * Session payload for integration OAuth init URLs. + * Stored server-side in Redis via AuthSessionStore. + */ +interface IntegrationInitSession { userId: string; agentId: string; integrationId: string; requestedScopes: string[]; accountId: string; threadContext?: ThreadContext; - expiresAt: number; } /** * Integration OAuth Module — handles OAuth init/callback for generic integrations. - * Supports incremental auth: request only new scopes when user already has some granted. + * Uses server-side Redis sessions instead of encrypted tokens in URLs. */ export class IntegrationOAuthModule { private oauth2Client: GenericOAuth2Client; @@ -44,6 +48,7 @@ export class IntegrationOAuthModule { private configService: IntegrationConfigService, private credentialStore: IntegrationCredentialStore, private stateStore: McpOAuthStateStore, + private sessionStore: AuthSessionStore, _publicGatewayUrl: string, private callbackUrl: string, private queue?: IMessageQueue, @@ -63,45 +68,71 @@ export class IntegrationOAuthModule { } /** - * Generate a secure encrypted token for the OAuth init URL. - * Contains userId, agentId, integrationId, requestedScopes, and expiry (15 min). + * Create a server-side session for the OAuth init URL. + * Returns an opaque session ID (no encrypted tokens in URLs). */ - generateSecureToken( + async createInitSession( userId: string, agentId: string, integrationId: string, requestedScopes: string[], accountId = "default", threadContext?: ThreadContext - ): string { - const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes - const payload: SecureTokenPayload = { + ): Promise { + const ttlMs = 15 * 60 * 1000; // 15 minutes + const { sessionId } = await this.sessionStore.createSession( + { + userId, + platform: threadContext?.platform || "unknown", + agentId, + }, + ttlMs + ); + + // Store integration-specific metadata alongside the session + const metaKey = `auth:session:meta:${sessionId}`; + const meta: IntegrationInitSession = { userId, agentId, integrationId, requestedScopes, accountId, threadContext, - expiresAt, }; - return encrypt(JSON.stringify(payload)); + const ttlSeconds = Math.ceil(ttlMs / 1000); + await this.sessionStore.redis.setex( + metaKey, + ttlSeconds, + JSON.stringify(meta) + ); + + return sessionId; } - private validateSecureToken(token: string): SecureTokenPayload | null { - try { - const decrypted = decrypt(token); - const data = JSON.parse(decrypted) as SecureTokenPayload; - - if (Date.now() > data.expiresAt) { - logger.warn("Integration OAuth token expired", { - integrationId: data.integrationId, - }); - return null; - } + /** + * Retrieve and consume integration init session metadata. + */ + private async consumeInitSession( + sessionId: string + ): Promise { + // Consume the auth session (one-time use) + const session = await this.sessionStore.consumeSession(sessionId); + if (!session) return null; + + // Retrieve and delete the integration metadata + const metaKey = `auth:session:meta:${sessionId}`; + const metaData = await this.sessionStore.redis.getdel(metaKey); + if (!metaData) { + logger.warn("Integration init session missing metadata", { + sessionId: `${sessionId.substring(0, 8)}...`, + }); + return null; + } - return data; - } catch (error) { - logger.error("Failed to validate integration token", { error }); + try { + return JSON.parse(metaData) as IntegrationInitSession; + } catch { + logger.error("Failed to parse integration init session metadata"); return null; } } @@ -113,28 +144,28 @@ export class IntegrationOAuthModule { } /** - * GET /api/v1/auth/integration/init/:id?token= - * Validates token, builds auth URL with incremental scope support, redirects. + * GET /api/v1/auth/integration/init/:id?s= + * Validates session, builds auth URL with incremental scope support, redirects. */ private async handleOAuthInit(c: Context): Promise { const integrationId = c.req.param("id"); - const token = c.req.query("token"); + const sessionId = c.req.query("s"); - if (!token) { - return c.json({ error: "Missing token parameter" }, 400); + if (!sessionId) { + return c.json({ error: "Missing session parameter" }, 400); } - const tokenData = this.validateSecureToken(token); - if (!tokenData) { - return c.json({ error: "Invalid or expired token" }, 401); + const initSession = await this.consumeInitSession(sessionId); + if (!initSession) { + return c.json({ error: "Invalid or expired session" }, 401); } - if (tokenData.integrationId !== integrationId) { - return c.json({ error: "Token integrationId mismatch" }, 400); + if (initSession.integrationId !== integrationId) { + return c.json({ error: "Session integrationId mismatch" }, 400); } const { userId, agentId, requestedScopes, accountId, threadContext } = - tokenData; + initSession; try { const config = await this.configService.getIntegration(integrationId); @@ -157,12 +188,10 @@ export class IntegrationOAuthModule { accountId ); if (existing?.grantedScopes?.length) { - // Only request the new scopes, include_granted_scopes preserves old ones const newScopes = requestedScopes.filter( (s) => !existing.grantedScopes.includes(s) ); if (newScopes.length === 0) { - // All scopes already granted return c.json({ message: "All requested scopes are already granted", }); @@ -173,8 +202,6 @@ export class IntegrationOAuthModule { } // Create state for CSRF protection - // Encode accountId alongside integrationId in mcpId field as {integrationId}:{accountId} - // Store thread context in redirectPath for post-auth notification const state = await this.stateStore.createWithNonce({ userId, agentId, @@ -353,7 +380,7 @@ export class IntegrationOAuthModule { `Integration OAuth successful for agent ${agentId}, integration ${integrationId}, account ${accountId}, scopes: ${finalScopes.join(", ")}` ); - // Invalidate worker's cached session context so it sees updated integration status + // Invalidate worker's cached session context this.connectionManager?.notifyAgent(agentId, "config_changed", { changes: [`integration:${integrationId}:${accountId}:connected`], }); @@ -382,10 +409,6 @@ export class IntegrationOAuthModule { } } - /** - * Send a system message to the thread so the agent knows authentication succeeded - * and can resume working with the new credentials. - */ private async sendAuthNotification( userId: string, agentId: string, diff --git a/packages/gateway/src/auth/integration/routes.ts b/packages/gateway/src/auth/integration/routes.ts index 3c83eee7c..ba867950b 100644 --- a/packages/gateway/src/auth/integration/routes.ts +++ b/packages/gateway/src/auth/integration/routes.ts @@ -181,8 +181,8 @@ export function createIntegrationRoutes( } } - // Generate OAuth URL with thread context for post-auth notification - const token = oauthModule.generateSecureToken( + // Create server-side session for the OAuth init URL (no encrypted tokens) + const sessionId = await oauthModule.createInitSession( worker.userId, agentId, integration, @@ -195,7 +195,7 @@ export function createIntegrationRoutes( platform: worker.platform || "slack", } ); - const oauthUrl = `${publicGatewayUrl}/api/v1/auth/integration/init/${integration}?token=${encodeURIComponent(token)}`; + const oauthUrl = `${publicGatewayUrl}/api/v1/auth/integration/init/${integration}?s=${encodeURIComponent(sessionId)}`; // Send login link to user via InteractionService if (interactionService) { diff --git a/packages/gateway/src/auth/settings/identity-store.ts b/packages/gateway/src/auth/settings/identity-store.ts new file mode 100644 index 000000000..68643e26b --- /dev/null +++ b/packages/gateway/src/auth/settings/identity-store.ts @@ -0,0 +1,130 @@ +import { BaseRedisStore, createLogger } from "@lobu/core"; +import type Redis from "ioredis"; + +const logger = createLogger("oauth-identity-store"); + +/** + * Record linking an OAuth provider identity to a platform user. + */ +export interface OAuthIdentityMapping { + /** Platform user ID (Slack: U12345, Telegram: 67890) */ + userId: string; + /** Messaging platform (slack, telegram, whatsapp) */ + platform: string; + /** When the mapping was first established */ + linkedAt: string; + /** When the mapping was last verified */ + lastVerifiedAt: string; +} + +/** + * Stores mappings between OAuth provider identities and platform users. + * + * Key pattern: oauth:identity:{provider}:{oauthSub} + * + * First access establishes the mapping (trusted because the settings link + * was sent to the correct user in chat). Subsequent accesses verify the + * mapping matches — if a different platform user tries to use the same + * OAuth identity, access is denied. + */ +export class OAuthIdentityStore extends BaseRedisStore { + constructor(redis: Redis) { + super({ + redis, + keyPrefix: "oauth:identity", + loggerName: "oauth-identity-store", + }); + } + + /** + * Link an OAuth identity to a platform user. + * Returns false if already linked to a DIFFERENT user. + */ + async linkIdentity( + provider: string, + oauthSub: string, + userId: string, + platform: string + ): Promise<{ linked: boolean; existingUserId?: string }> { + const key = this.buildKey(provider, oauthSub); + const existing = await this.get(key); + + if (existing) { + if (existing.userId === userId && existing.platform === platform) { + // Same user — update lastVerifiedAt + existing.lastVerifiedAt = new Date().toISOString(); + await this.set(key, existing); + logger.info("OAuth identity re-verified", { + provider, + oauthSub: `${oauthSub.substring(0, 8)}...`, + userId, + }); + return { linked: true }; + } + + // Different user — reject + logger.warn("OAuth identity already linked to different user", { + provider, + oauthSub: `${oauthSub.substring(0, 8)}...`, + existingUserId: existing.userId, + attemptedUserId: userId, + }); + return { linked: false, existingUserId: existing.userId }; + } + + // New mapping + const mapping: OAuthIdentityMapping = { + userId, + platform, + linkedAt: new Date().toISOString(), + lastVerifiedAt: new Date().toISOString(), + }; + await this.set(key, mapping); + + logger.info("OAuth identity linked", { + provider, + oauthSub: `${oauthSub.substring(0, 8)}...`, + userId, + platform, + }); + return { linked: true }; + } + + /** + * Verify that an OAuth identity maps to the expected platform user. + */ + async verifyIdentity( + provider: string, + oauthSub: string, + expectedUserId: string + ): Promise { + const key = this.buildKey(provider, oauthSub); + const mapping = await this.get(key); + + if (!mapping) { + // No mapping exists yet — caller should create one + return true; + } + + return mapping.userId === expectedUserId; + } + + /** + * Get the platform user ID for an OAuth identity. + */ + async getMapping( + provider: string, + oauthSub: string + ): Promise { + const key = this.buildKey(provider, oauthSub); + return this.get(key); + } + + /** + * Remove an OAuth identity mapping. + */ + async unlinkIdentity(provider: string, oauthSub: string): Promise { + const key = this.buildKey(provider, oauthSub); + await this.delete(key); + } +} diff --git a/packages/gateway/src/auth/settings/index.ts b/packages/gateway/src/auth/settings/index.ts index a056f6fe4..36f0a40c7 100644 --- a/packages/gateway/src/auth/settings/index.ts +++ b/packages/gateway/src/auth/settings/index.ts @@ -4,6 +4,13 @@ export { createAuthProfileLabel, type UpsertAuthProfileInput, } from "./auth-profiles-manager"; +export { OAuthIdentityStore } from "./identity-store"; +export { SettingsOAuthProvider } from "./oauth-provider"; +export { + AuthSessionStore, + buildIntegrationInitUrl, + buildSessionUrl, +} from "./session-store"; export { buildSettingsUrl, buildTelegramSettingsUrl, @@ -13,6 +20,7 @@ export { getSettingsTokenTtlMs, type PrefillMcpServer, type PrefillSkill, + type SettingsSessionPayload, type SettingsSourceContext, type SettingsTokenOptions, type SettingsTokenPayload, diff --git a/packages/gateway/src/auth/settings/oauth-provider.ts b/packages/gateway/src/auth/settings/oauth-provider.ts new file mode 100644 index 000000000..f1113d6a2 --- /dev/null +++ b/packages/gateway/src/auth/settings/oauth-provider.ts @@ -0,0 +1,223 @@ +import { createLogger } from "@lobu/core"; +import type Redis from "ioredis"; +import type { OAuth2Config } from "../mcp/config-service"; +import { GenericOAuth2Client } from "../oauth/generic-client"; +import { OAuthStateStore } from "../oauth/state-store"; + +const logger = createLogger("settings-oauth-provider"); + +/** + * OAuth state data for settings authentication flow. + */ +export interface SettingsOAuthStateData { + userId: string; + /** The auth:session:{uuid} session ID */ + sessionId: string; + platform: string; +} + +/** + * User info returned after OAuth token exchange. + * The `sub` field is the unique identifier from the OAuth provider. + */ +export interface OAuthUserInfo { + sub: string; + email?: string; + name?: string; +} + +/** + * Configuration for the settings page OAuth provider. + * + * Read from environment variables: + * - SETTINGS_OAUTH_AUTH_URL + * - SETTINGS_OAUTH_TOKEN_URL + * - SETTINGS_OAUTH_CLIENT_ID + * - SETTINGS_OAUTH_CLIENT_SECRET (supports ${env:VAR} substitution) + * - SETTINGS_OAUTH_SCOPES (comma-separated) + * - SETTINGS_OAUTH_USERINFO_URL (endpoint returning {sub, email?, name?}) + * - SETTINGS_OAUTH_PROVIDER_NAME (display name, e.g. "Owletto") + */ +export interface SettingsOAuthConfig { + oauth: OAuth2Config; + userinfoUrl: string; + providerName: string; +} + +/** + * Provider-agnostic OAuth authentication for the settings page. + * + * Uses the same GenericOAuth2Client as MCP OAuth, consolidating + * the OAuth infrastructure into a single pattern. + */ +export class SettingsOAuthProvider { + private oauth2Client: GenericOAuth2Client; + private stateStore: OAuthStateStore; + private config: SettingsOAuthConfig; + private callbackUrl: string; + + constructor( + redis: Redis, + config: SettingsOAuthConfig, + publicGatewayUrl: string + ) { + this.oauth2Client = new GenericOAuth2Client(); + this.stateStore = new OAuthStateStore( + redis, + "settings:oauth:state", + "settings-oauth-state" + ); + this.config = config; + this.callbackUrl = `${publicGatewayUrl}/settings/oauth/callback`; + } + + /** + * Check if a settings OAuth provider is configured via environment variables. + */ + static fromEnv( + redis: Redis, + publicGatewayUrl: string + ): SettingsOAuthProvider | null { + const authUrl = process.env.SETTINGS_OAUTH_AUTH_URL; + const tokenUrl = process.env.SETTINGS_OAUTH_TOKEN_URL; + const clientId = process.env.SETTINGS_OAUTH_CLIENT_ID; + const clientSecret = process.env.SETTINGS_OAUTH_CLIENT_SECRET; + const userinfoUrl = process.env.SETTINGS_OAUTH_USERINFO_URL; + + if (!authUrl || !tokenUrl || !clientId || !clientSecret || !userinfoUrl) { + return null; + } + + const scopes = process.env.SETTINGS_OAUTH_SCOPES?.split(",").map((s) => + s.trim() + ); + const providerName = + process.env.SETTINGS_OAUTH_PROVIDER_NAME || "OAuth Provider"; + + logger.info(`Settings OAuth provider configured: ${providerName}`); + + return new SettingsOAuthProvider( + redis, + { + oauth: { + authUrl, + tokenUrl, + clientId, + clientSecret, + scopes, + grantType: "authorization_code", + responseType: "code", + }, + userinfoUrl, + providerName, + }, + publicGatewayUrl + ); + } + + get providerName(): string { + return this.config.providerName; + } + + /** + * Start the OAuth flow. Creates state and returns the auth URL. + */ + async startAuth( + userId: string, + sessionId: string, + platform: string + ): Promise { + const state = await this.stateStore.create({ + userId, + sessionId, + platform, + }); + + return this.oauth2Client.buildAuthUrl( + this.config.oauth, + state, + this.callbackUrl + ); + } + + /** + * Handle the OAuth callback. Validates state, exchanges code, fetches user info. + */ + async handleCallback( + code: string, + state: string + ): Promise<{ + stateData: SettingsOAuthStateData & { createdAt: number }; + userInfo: OAuthUserInfo; + } | null> { + const stateData = await this.stateStore.consume(state); + if (!stateData) { + logger.warn("Invalid or expired OAuth state"); + return null; + } + + // Exchange code for token + const credentials = await this.oauth2Client.exchangeCodeForToken( + code, + this.config.oauth, + this.callbackUrl + ); + + // Fetch user info from the provider + const userInfo = await this.fetchUserInfo(credentials.accessToken); + if (!userInfo) { + logger.error("Failed to fetch user info from OAuth provider"); + return null; + } + + return { stateData, userInfo }; + } + + /** + * Fetch user info from the OAuth provider's userinfo endpoint. + */ + private async fetchUserInfo( + accessToken: string + ): Promise { + try { + const response = await fetch(this.config.userinfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + logger.error("Userinfo endpoint returned error", { + status: response.status, + }); + return null; + } + + const data = (await response.json()) as Record; + + // Support common userinfo response formats: + // Standard: { sub: "...", email: "...", name: "..." } + // GitHub: { id: 123, login: "..." } + // Google: { sub: "...", email: "..." } + const sub = String( + data.sub || data.id || data.user_id || data.login || "" + ); + if (!sub) { + logger.error("Userinfo response missing user identifier (sub/id)"); + return null; + } + + return { + sub, + email: data.email as string | undefined, + name: (data.name || data.display_name || data.login) as + | string + | undefined, + }; + } catch (error) { + logger.error("Failed to fetch userinfo", { error }); + return null; + } + } +} diff --git a/packages/gateway/src/auth/settings/session-store.ts b/packages/gateway/src/auth/settings/session-store.ts new file mode 100644 index 000000000..532f9342c --- /dev/null +++ b/packages/gateway/src/auth/settings/session-store.ts @@ -0,0 +1,139 @@ +import { randomBytes } from "node:crypto"; +import { BaseRedisStore, createLogger } from "@lobu/core"; +import type Redis from "ioredis"; +import type { SettingsSessionPayload } from "./token-service"; + +const logger = createLogger("auth-session-store"); + +/** + * Redis-backed session store for all auth flows. + * + * Replaces encrypted tokens in URLs with opaque session IDs. + * Context (userId, agentId, prefills, etc.) lives server-side in Redis. + * + * Key pattern: auth:session:{uuid} + */ +export class AuthSessionStore extends BaseRedisStore { + constructor(redis: Redis) { + super({ + redis, + keyPrefix: "auth:session", + loggerName: "auth-session-store", + }); + } + + /** + * Create a new session with the given payload. + * Returns an opaque session ID (URL-safe random string). + */ + async createSession( + payload: Omit, + ttlMs: number + ): Promise<{ sessionId: string; expiresAt: number }> { + const sessionId = randomBytes(32).toString("base64url"); + const expiresAt = Date.now() + ttlMs; + const fullPayload: SettingsSessionPayload = { ...payload, exp: expiresAt }; + + const ttlSeconds = Math.ceil(ttlMs / 1000); + const key = this.buildKey(sessionId); + await this.set(key, fullPayload, ttlSeconds); + + logger.info("Created auth session", { + sessionId: `${sessionId.substring(0, 8)}...`, + userId: payload.userId, + agentId: payload.agentId, + platform: payload.platform, + }); + + return { sessionId, expiresAt }; + } + + /** + * Look up a session by ID. + * Returns the payload if found and not expired, null otherwise. + */ + async getSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + const payload = await this.get(key); + if (!payload) return null; + + // Double-check expiry (Redis TTL is authoritative, but belt-and-suspenders) + if (Date.now() > payload.exp) { + logger.warn("Session expired (clock check)", { + sessionId: `${sessionId.substring(0, 8)}...`, + }); + await this.delete(key); + return null; + } + + return payload; + } + + /** + * Consume a session (one-time use). Returns payload and deletes. + * Used for integration init URLs and MCP login URLs. + */ + async consumeSession( + sessionId: string + ): Promise { + const key = this.buildKey(sessionId); + + // Atomic get+delete + const data = await this.redis.getdel(key); + if (!data) return null; + + try { + const payload = JSON.parse(data) as SettingsSessionPayload; + + if (Date.now() > payload.exp) { + logger.warn("Consumed session was expired", { + sessionId: `${sessionId.substring(0, 8)}...`, + }); + return null; + } + + return payload; + } catch (error) { + logger.error("Failed to parse session data", { error }); + return null; + } + } + + /** + * Delete a session explicitly. + */ + async deleteSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + await this.delete(key); + } + + override validate(value: SettingsSessionPayload): boolean { + return !!value.userId && !!value.platform && !!value.exp; + } +} + +/** + * Build a settings URL with session ID. + * Session ID travels in the URL hash to avoid logging/referrer leaks. + */ +export function buildSessionUrl( + sessionId: string, + opts?: { useQueryParam?: boolean } +): string { + const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; + if (opts?.useQueryParam) { + return `${baseUrl}/settings?s=${encodeURIComponent(sessionId)}`; + } + return `${baseUrl}/settings#s=${encodeURIComponent(sessionId)}`; +} + +/** + * Build an integration init URL with session ID. + */ +export function buildIntegrationInitUrl( + integrationId: string, + sessionId: string +): string { + const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; + return `${baseUrl}/api/v1/auth/integration/init/${integrationId}?s=${encodeURIComponent(sessionId)}`; +} diff --git a/packages/gateway/src/auth/settings/token-service.ts b/packages/gateway/src/auth/settings/token-service.ts index f2ad7be32..c00869968 100644 --- a/packages/gateway/src/auth/settings/token-service.ts +++ b/packages/gateway/src/auth/settings/token-service.ts @@ -53,7 +53,11 @@ export interface SettingsSourceContext { * - Channel-based: `channelId` is set, `agentId` may be absent (from message handlers when no agent bound) * At least one of `agentId` or `channelId` must be present. */ -export interface SettingsTokenPayload { +/** + * Canonical session payload type. Stored server-side in Redis. + * SettingsTokenPayload is kept as an alias for backward compatibility. + */ +export interface SettingsSessionPayload { /** Agent to configure. Optional when using channel-based entry (user picks agent on page). */ agentId?: string; userId: string; @@ -79,6 +83,9 @@ export interface SettingsTokenPayload { sourceContext?: SettingsSourceContext; } +/** @deprecated Use SettingsSessionPayload instead. */ +export type SettingsTokenPayload = SettingsSessionPayload; + /** * Default TTL for settings tokens (1 hour) */ @@ -190,7 +197,7 @@ export function generateSettingsToken( ); } - const payload: SettingsTokenPayload = { + const payload: SettingsSessionPayload = { userId, platform, exp: Date.now() + ttlMs, diff --git a/packages/gateway/src/cli/gateway.ts b/packages/gateway/src/cli/gateway.ts index 51985c94b..95eddfd4b 100644 --- a/packages/gateway/src/cli/gateway.ts +++ b/packages/gateway/src/cli/gateway.ts @@ -217,12 +217,20 @@ function setupServer( } } + // Initialize auth session store for settings-auth module + if (coreServices) { + const { setSessionStore } = require("../routes/public/settings-auth"); + setSessionStore(coreServices.getAuthSessionStore()); + logger.info("Auth session store wired into settings-auth"); + } + // Settings link routes (worker can generate settings links for users) { const { createSettingsLinkRoutes, } = require("../routes/internal/settings-link"); const settingsLinkRouter = createSettingsLinkRoutes( + coreServices!.getAuthSessionStore(), interactionService, coreServices?.getGrantStore() ); @@ -523,6 +531,9 @@ function setupServer( userAgentsStore: coreServices.getUserAgentsStore(), agentMetadataStore: coreServices.getAgentMetadataStore(), channelBindingService: coreServices.getChannelBindingService(), + sessionStore: coreServices.getAuthSessionStore(), + oauthProvider: coreServices.getSettingsOAuthProvider(), + identityStore: coreServices.getOAuthIdentityStore(), integrationConfigService: coreServices.getIntegrationConfigService(), integrationCredentialStore: coreServices.getIntegrationCredentialStore(), diff --git a/packages/gateway/src/routes/internal/settings-link.ts b/packages/gateway/src/routes/internal/settings-link.ts index bf6d75fce..d6a977032 100644 --- a/packages/gateway/src/routes/internal/settings-link.ts +++ b/packages/gateway/src/routes/internal/settings-link.ts @@ -2,15 +2,15 @@ * Internal Settings Link Routes * * Worker-facing endpoint for generating settings magic links. - * Used by the GetSettingsLink custom MCP tool. + * Uses server-side Redis sessions (no encrypted tokens in URLs). */ import { createLogger, verifyWorkerToken } from "@lobu/core"; import { Hono } from "hono"; +import type { AuthSessionStore } from "../../auth/settings/session-store"; +import { buildSessionUrl } from "../../auth/settings/session-store"; import { - buildSettingsUrl, buildTelegramSettingsUrl, - generateSettingsToken, getSettingsTokenTtlMs, type PrefillMcpServer, type PrefillSkill, @@ -38,6 +38,7 @@ type WorkerContext = { * Create internal settings link routes (Hono) */ export function createSettingsLinkRoutes( + sessionStore: AuthSessionStore, interactionService?: InteractionService, grantStore?: GrantStore ): Hono { @@ -59,21 +60,11 @@ export function createSettingsLinkRoutes( }; /** - * Generate a settings magic link for the current user/agent context - * POST /internal/settings-link - * - * Body: { - * reason?: string (optional explanation for what to configure) - * message?: string (optional message to display on settings page) - * prefillEnvVars?: string[] (optional env var keys to pre-fill) - * prefillSkills?: PrefillSkill[] (optional skills to pre-fill) - * prefillMcpServers?: PrefillMcpServer[] (optional MCP servers to pre-fill) - * } + * Generate a settings link for the current user/agent context. + * Context is stored server-side in Redis — only an opaque session ID + * appears in the URL. * - * Response: { - * url: string (settings page URL with magic token) - * expiresAt: string (ISO timestamp when link expires) - * } + * POST /internal/settings-link */ router.post("/internal/settings-link", authenticateWorker, async (c) => { try { @@ -153,7 +144,7 @@ export function createSettingsLinkRoutes( }); } - // Telegram plain "Open Settings" links use stable URLs (no token needed) + // Telegram plain "Open Settings" links use stable URLs (no session needed) const hasPrefillData = prefillSkills?.length || prefillMcpServers?.length || @@ -183,32 +174,41 @@ export function createSettingsLinkRoutes( }); } - // Generate token with configured TTL (defaults to 1 hour) + // Create server-side session (no encrypted token in URL) const ttlMs = getSettingsTokenTtlMs(); - const token = generateSettingsToken(agentId, userId, platform, { - ttlMs, - channelId: worker.channelId, - teamId: worker.teamId, - message, - prefillEnvVars, - prefillSkills, - prefillMcpServers, - prefillNixPackages, - prefillGrants, - sourceContext: { - conversationId: worker.conversationId, + const { sessionId, expiresAt } = await sessionStore.createSession( + { + userId, + platform, + agentId, channelId: worker.channelId, teamId: worker.teamId, - platform, + message, + prefillEnvVars, + prefillSkills, + prefillMcpServers, + prefillNixPackages, + prefillGrants, + sourceContext: { + conversationId: worker.conversationId, + channelId: worker.channelId, + teamId: worker.teamId, + platform, + }, }, - }); + ttlMs + ); + // Telegram web_app buttons replace URL hash fragments, so use query param - const url = buildSettingsUrl(token, { + const url = buildSessionUrl(sessionId, { useQueryParam: platform === "telegram", }); - const expiresAt = new Date(Date.now() + ttlMs).toISOString(); - logger.info("Settings link generated", { agentId, userId, expiresAt }); + logger.info("Settings link generated (session-based)", { + agentId, + userId, + expiresAt: new Date(expiresAt).toISOString(), + }); // Fire link button event so platforms render natively if (interactionService) { @@ -240,12 +240,15 @@ export function createSettingsLinkRoutes( } // Fallback: no interaction service (shouldn't happen in practice). - // Never return the raw URL/token to the worker — it would be visible - // to the agent, which is a security risk. - logger.warn("No interactionService available — settings link generated but cannot be delivered to user", { agentId, userId }); + // Never return the raw URL to the worker. + logger.warn( + "No interactionService available — settings link generated but cannot be delivered to user", + { agentId, userId } + ); return c.json({ type: "settings_link", - message: "Settings link generated but could not be delivered (no interaction service).", + message: + "Settings link generated but could not be delivered (no interaction service).", }); } catch (error) { logger.error("Failed to generate settings link", { error }); diff --git a/packages/gateway/src/routes/public/agent-config.ts b/packages/gateway/src/routes/public/agent-config.ts index 88caa0c4b..03d9ec3d5 100644 --- a/packages/gateway/src/routes/public/agent-config.ts +++ b/packages/gateway/src/routes/public/agent-config.ts @@ -395,7 +395,7 @@ export function createAgentConfigRoutes( app.openapi(getConfigRoute, async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); const settings = await config.agentSettingsStore.getSettings(agentId); @@ -451,7 +451,7 @@ export function createAgentConfigRoutes( app.openapi(updateConfigRoute, async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); try { @@ -530,7 +530,7 @@ export function createAgentConfigRoutes( // GET /packages/search?q=python app.get("/packages/search", async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); const query = (c.req.query("q") || "").trim(); @@ -552,7 +552,7 @@ export function createAgentConfigRoutes( // GET /providers/catalog app.get("/providers/catalog", async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); if (!config.providerCatalogService) { @@ -580,7 +580,7 @@ export function createAgentConfigRoutes( app.put("/providers/:providerId", async (c): Promise => { const agentId = c.req.param("agentId") || ""; const providerId = c.req.param("providerId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); if (!config.providerCatalogService) { @@ -638,7 +638,7 @@ export function createAgentConfigRoutes( // PATCH /providers/reorder app.patch("/providers/reorder", async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); if (!config.providerCatalogService) { @@ -682,7 +682,7 @@ export function createAgentConfigRoutes( // GET /grants - List all active grants app.get("/grants", async (c) => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); const grants = await grantStore.listGrants(agentId); @@ -692,7 +692,7 @@ export function createAgentConfigRoutes( // POST /grants - Create a grant app.post("/grants", async (c) => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); const body = await c.req.json<{ @@ -722,7 +722,7 @@ export function createAgentConfigRoutes( app.delete("/grants/:pattern", async (c) => { const agentId = c.req.param("agentId") || ""; const pattern = decodeURIComponent(c.req.param("pattern") || ""); - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); await grantStore.revoke(agentId, pattern); diff --git a/packages/gateway/src/routes/public/agent-history.ts b/packages/gateway/src/routes/public/agent-history.ts index 226610302..ee34655a4 100644 --- a/packages/gateway/src/routes/public/agent-history.ts +++ b/packages/gateway/src/routes/public/agent-history.ts @@ -11,8 +11,8 @@ import { verifySettingsSession } from "./settings-auth"; const logger = createLogger("agent-history-routes"); -function getAgentId(c: Context): string | null { - const session = verifySettingsSession(c); +async function getAgentId(c: Context): Promise { + const session = await verifySettingsSession(c); if (!session) return null; return c.req.param("agentId") || session.agentId || null; } @@ -24,8 +24,8 @@ export function createAgentHistoryRoutes(deps: { const { connectionManager } = deps; // Agent status (connected, httpUrl available) - app.get("/status", (c) => { - const agentId = getAgentId(c); + app.get("/status", async (c) => { + const agentId = await getAgentId(c); if (!agentId) return c.json({ error: "Unauthorized" }, 401); const deployments = connectionManager.getDeploymentsForAgent(agentId); @@ -40,7 +40,7 @@ export function createAgentHistoryRoutes(deps: { // Proxy session messages to worker app.get("/session/messages", async (c) => { - const agentId = getAgentId(c); + const agentId = await getAgentId(c); if (!agentId) return c.json({ error: "Unauthorized" }, 401); const httpUrl = connectionManager.getHttpUrl(agentId); @@ -80,7 +80,7 @@ export function createAgentHistoryRoutes(deps: { // Proxy session stats to worker app.get("/session/stats", async (c) => { - const agentId = getAgentId(c); + const agentId = await getAgentId(c); if (!agentId) return c.json({ error: "Unauthorized" }, 401); const httpUrl = connectionManager.getHttpUrl(agentId); diff --git a/packages/gateway/src/routes/public/agent-schedules.ts b/packages/gateway/src/routes/public/agent-schedules.ts index 8cfe35f58..71a52ad0e 100644 --- a/packages/gateway/src/routes/public/agent-schedules.ts +++ b/packages/gateway/src/routes/public/agent-schedules.ts @@ -128,7 +128,7 @@ export function createAgentSchedulesRoutes( app.openapi(listSchedulesRoute, async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); if (!config.scheduledWakeupService) return c.json({ schedules: [] }); @@ -149,7 +149,7 @@ export function createAgentSchedulesRoutes( app.openapi(cancelScheduleRoute, async (c): Promise => { const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(verifySettingsSession(c), agentId); + const payload = await verifyToken(await verifySettingsSession(c), agentId); if (!payload) return c.json({ error: "Unauthorized" }, 401); if (!config.scheduledWakeupService) { diff --git a/packages/gateway/src/routes/public/agents.ts b/packages/gateway/src/routes/public/agents.ts index 9848b45a5..5989197ed 100644 --- a/packages/gateway/src/routes/public/agents.ts +++ b/packages/gateway/src/routes/public/agents.ts @@ -47,7 +47,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // POST /api/v1/agents - Create a new agent router.post("/", async (c) => { - const payload = verifySettingsSession(c); + const payload = await verifySettingsSession(c); if (!payload) { return c.json({ error: "Invalid or expired token" }, 401); } @@ -150,7 +150,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // GET /api/v1/agents - List user's agents router.get("/", async (c) => { - const payload = verifySettingsSession(c); + const payload = await verifySettingsSession(c); if (!payload) { return c.json({ error: "Invalid or expired token" }, 401); } @@ -188,7 +188,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // PATCH /api/v1/manage/agents/{agentId} - Update agent name/description router.patch("/:agentId", async (c) => { - const payload = verifySettingsSession(c); + const payload = await verifySettingsSession(c); if (!payload) { return c.json({ error: "Invalid or expired token" }, 401); } @@ -256,7 +256,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // DELETE /api/v1/manage/agents/{agentId} - Delete an agent router.delete("/:agentId", async (c) => { - const payload = verifySettingsSession(c); + const payload = await verifySettingsSession(c); if (!payload) { return c.json({ error: "Invalid or expired token" }, 401); } diff --git a/packages/gateway/src/routes/public/integrations.ts b/packages/gateway/src/routes/public/integrations.ts index daeefa200..31a60f95d 100644 --- a/packages/gateway/src/routes/public/integrations.ts +++ b/packages/gateway/src/routes/public/integrations.ts @@ -177,7 +177,7 @@ export function createIntegrationsRoutes(): OpenAPIHono { app.openapi(registryRoute, async (c): Promise => { const { q, limit } = c.req.valid("query"); - if (!verifySettingsSession(c)) + if (!(await verifySettingsSession(c))) return c.json({ error: "Unauthorized" }, 401); const maxLimit = Math.min(parseInt(limit || "20", 10), 50); @@ -208,7 +208,7 @@ export function createIntegrationsRoutes(): OpenAPIHono { }); app.openapi(skillFetchRoute, async (c): Promise => { - if (!verifySettingsSession(c)) + if (!(await verifySettingsSession(c))) return c.json({ error: "Unauthorized" }, 401); const { repo } = c.req.valid("json"); @@ -234,7 +234,7 @@ export function createIntegrationsRoutes(): OpenAPIHono { }); app.openapi(mcpByIdRoute, async (c): Promise => { - if (!verifySettingsSession(c)) + if (!(await verifySettingsSession(c))) return c.json({ error: "Unauthorized" }, 401); const { id } = c.req.valid("param"); diff --git a/packages/gateway/src/routes/public/oauth.ts b/packages/gateway/src/routes/public/oauth.ts index 71025cae2..63d5b1016 100644 --- a/packages/gateway/src/routes/public/oauth.ts +++ b/packages/gateway/src/routes/public/oauth.ts @@ -80,7 +80,7 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { // --- Provider login redirect (excluded from docs) --- app.get("/:provider/login", async (c) => { - const payload = verifyToken(verifySettingsSession(c)); + const payload = verifyToken(await verifySettingsSession(c)); if (!payload) return c.json({ error: "Unauthorized" }, 401); const provider = c.req.param("provider"); @@ -102,7 +102,7 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { // --- Provider code exchange --- app.openapi(codeExchangeRoute, async (c): Promise => { - const payload = verifyToken(verifySettingsSession(c)); + const payload = verifyToken(await verifySettingsSession(c)); if (!payload) return c.json({ error: "Unauthorized" }, 401); const { provider } = c.req.valid("param"); diff --git a/packages/gateway/src/routes/public/settings-auth.ts b/packages/gateway/src/routes/public/settings-auth.ts index a08ea66ef..27de5ac13 100644 --- a/packages/gateway/src/routes/public/settings-auth.ts +++ b/packages/gateway/src/routes/public/settings-auth.ts @@ -1,23 +1,40 @@ import type { Context } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { - type SettingsTokenPayload, - verifySettingsToken, -} from "../../auth/settings/token-service"; +import type { AuthSessionStore } from "../../auth/settings/session-store"; +import type { SettingsSessionPayload } from "../../auth/settings/token-service"; -export const SETTINGS_TOKEN_QUERY_PARAM = "token"; export const SETTINGS_SESSION_COOKIE_NAME = "lobu_settings_session"; -function getTokenFromQuery(c: Context): string | undefined { - const token = c.req.query(SETTINGS_TOKEN_QUERY_PARAM); - if (!token || token.trim().length === 0) return undefined; - return token; +/** + * Singleton reference to the session store. + * Set once during app initialization via `setSessionStore()`. + */ +let _sessionStore: AuthSessionStore | undefined; + +export function setSessionStore(store: AuthSessionStore): void { + _sessionStore = store; +} + +function getSessionStore(): AuthSessionStore { + if (!_sessionStore) { + throw new Error( + "AuthSessionStore not initialized — call setSessionStore() first" + ); + } + return _sessionStore; } -function getTokenFromCookie(c: Context): string | undefined { - const token = getCookie(c, SETTINGS_SESSION_COOKIE_NAME); - if (!token || token.trim().length === 0) return undefined; - return token; +function getSessionIdFromQuery(c: Context): string | undefined { + // New session-based param + const sid = c.req.query("s"); + if (sid && sid.trim().length > 0) return sid.trim(); + return undefined; +} + +function getSessionIdFromCookie(c: Context): string | undefined { + const sid = getCookie(c, SETTINGS_SESSION_COOKIE_NAME); + if (!sid || sid.trim().length === 0) return undefined; + return sid.trim(); } function isSecureRequest(c: Context): boolean { @@ -28,30 +45,41 @@ function isSecureRequest(c: Context): boolean { return new URL(c.req.url).protocol === "https:"; } -export function resolveSettingsToken(c: Context): string | undefined { - return getTokenFromQuery(c) ?? getTokenFromCookie(c); +/** + * Resolve the session ID from query param or cookie. + */ +export function resolveSessionId(c: Context): string | undefined { + return getSessionIdFromQuery(c) ?? getSessionIdFromCookie(c); } -export function verifySettingsSession(c: Context): SettingsTokenPayload | null { - const token = resolveSettingsToken(c); - if (!token) return null; - return verifySettingsToken(token); +/** + * Verify the current settings session. + * Looks up the session ID in Redis and returns the payload if valid. + */ +export async function verifySettingsSession( + c: Context +): Promise { + const sessionId = resolveSessionId(c); + if (!sessionId) return null; + + const store = getSessionStore(); + return store.getSession(sessionId); } +/** + * Set the session cookie with the session ID. + */ export function setSettingsSessionCookie( c: Context, - token: string, - payload?: SettingsTokenPayload + sessionId: string, + payload: SettingsSessionPayload ): boolean { - const verifiedPayload = payload ?? verifySettingsToken(token); - if (!verifiedPayload) return false; - const maxAgeSeconds = Math.max( 1, - Math.floor((verifiedPayload.exp - Date.now()) / 1000) + Math.floor((payload.exp - Date.now()) / 1000) ); - setCookie(c, SETTINGS_SESSION_COOKIE_NAME, token, { + setCookie(c, SETTINGS_SESSION_COOKIE_NAME, sessionId, { path: "/", httpOnly: true, sameSite: "Lax", diff --git a/packages/gateway/src/routes/public/settings.ts b/packages/gateway/src/routes/public/settings.ts index 5f6653e7b..53d1310d3 100644 --- a/packages/gateway/src/routes/public/settings.ts +++ b/packages/gateway/src/routes/public/settings.ts @@ -1,26 +1,28 @@ /** * Settings Page Routes * - * Serves the unified settings/agent-selector page via magic link. - * Supports two entry modes: - * - Agent-based token: shows settings directly - * - Channel-based token: resolves agent via binding or shows agent picker + * Serves the unified settings/agent-selector page. + * Authentication uses server-side Redis sessions (no encrypted tokens in URLs). + * + * Supports three entry modes: + * - Session-based: opaque session ID in URL, context lives in Redis + * - Telegram initData: HMAC-signed by bot token, creates Redis session + * - OAuth (optional): configurable provider for identity verification * * API endpoints (agent config, schedules, etc.) remain in separate files. */ import { OpenAPIHono } from "@hono/zod-openapi"; -import { encrypt } from "@lobu/core"; +import { createLogger } from "@lobu/core"; import type { AgentMetadata, AgentMetadataStore, } from "../../auth/agent-metadata-store"; import { collectProviderModelOptions } from "../../auth/provider-model-options"; import type { AgentSettingsStore } from "../../auth/settings"; -import { - type SettingsTokenPayload, - verifySettingsToken, -} from "../../auth/settings/token-service"; +import type { OAuthIdentityStore } from "../../auth/settings/identity-store"; +import type { SettingsOAuthProvider } from "../../auth/settings/oauth-provider"; +import type { AuthSessionStore } from "../../auth/settings/session-store"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChannelBindingService } from "../../channels"; import { getModelProviderModules } from "../../modules/module-system"; @@ -38,11 +40,16 @@ import { renderSettingsPage, } from "./settings-page"; +const logger = createLogger("settings-routes"); + export interface SettingsPageConfig { agentSettingsStore: AgentSettingsStore; userAgentsStore: UserAgentsStore; agentMetadataStore: AgentMetadataStore; channelBindingService: ChannelBindingService; + sessionStore: AuthSessionStore; + oauthProvider?: SettingsOAuthProvider; + identityStore?: OAuthIdentityStore; integrationConfigService?: import("../../auth/integration/config-service").IntegrationConfigService; integrationCredentialStore?: import("../../auth/integration/credential-store").IntegrationCredentialStore; connectionManager?: import("../../gateway/connection-manager").WorkerConnectionManager; @@ -72,11 +79,24 @@ export function createSettingsPageRoutes( ): OpenAPIHono { const app = new OpenAPIHono(); + // ========================================================================= + // POST /settings/session — establish a session cookie + // ========================================================================= app.post("/settings/session", async (c) => { const body = await c.req - .json<{ token?: string; initData?: string; chatId?: string }>() + .json<{ + sessionId?: string; + token?: string; + initData?: string; + chatId?: string; + }>() .catch( - (): { token?: string; initData?: string; chatId?: string } => ({}) + (): { + sessionId?: string; + token?: string; + initData?: string; + chatId?: string; + } => ({}) ); // Path A: Telegram WebApp initData authentication @@ -105,55 +125,155 @@ export function createSettingsPageRoutes( return c.json({ error: "Chat ID mismatch" }, 403); } - // Build a synthetic payload (1-hour session, matching token-based flow) + // Create a server-side session (replaces the old encrypt-to-cookie approach) const sessionTtlMs = 60 * 60 * 1000; - const payload: SettingsTokenPayload = { - userId, - platform: "telegram", - channelId: chatId, - exp: Date.now() + sessionTtlMs, - }; - - // Encrypt payload into a token for the session cookie - const syntheticToken = encrypt(JSON.stringify(payload)); - - const sessionSet = setSettingsSessionCookie(c, syntheticToken, payload); - if (!sessionSet) { + const { sessionId } = await config.sessionStore.createSession( + { + userId, + platform: "telegram", + channelId: chatId, + }, + sessionTtlMs + ); + + const payload = await config.sessionStore.getSession(sessionId); + if (!payload) { clearSettingsSessionCookie(c); return c.json({ error: "Failed to create session" }, 500); } + setSettingsSessionCookie(c, sessionId, payload); return c.json({ success: true }); } - // Path B: Existing token-based authentication - const token = (body.token ?? "").trim(); - if (!token) return c.json({ error: "Missing token" }, 400); + // Path B: Session-based authentication (new — opaque session ID) + const sessionId = (body.sessionId ?? body.token ?? "").trim(); + if (!sessionId) return c.json({ error: "Missing session ID" }, 400); - const payload = verifySettingsToken(token); + const payload = await config.sessionStore.getSession(sessionId); if (!payload) { clearSettingsSessionCookie(c); - return c.json({ error: "Invalid or expired token" }, 401); + return c.json({ error: "Invalid or expired session" }, 401); } - const sessionSet = setSettingsSessionCookie(c, token, payload); - if (!sessionSet) { - clearSettingsSessionCookie(c); - return c.json({ error: "Invalid or expired token" }, 401); + // If OAuth provider is configured, redirect to OAuth instead of setting cookie directly + if (config.oauthProvider) { + const authUrl = await config.oauthProvider.startAuth( + payload.userId, + sessionId, + payload.platform + ); + return c.json({ oauthRedirect: authUrl }); } + setSettingsSessionCookie(c, sessionId, payload); return c.json({ success: true }); }); - // HTML Settings Page + // ========================================================================= + // GET /settings/oauth/callback — OAuth identity verification callback + // ========================================================================= + if (config.oauthProvider && config.identityStore) { + const oauthProvider = config.oauthProvider; + const identityStore = config.identityStore; + + app.get("/settings/oauth/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + const error = c.req.query("error"); + + if (error) { + logger.warn("Settings OAuth error", { + error, + description: c.req.query("error_description"), + }); + return c.html( + renderErrorPage( + `Authentication failed: ${error}. Please request a new settings link.` + ), + 401 + ); + } + + if (!code || !state) { + return c.html( + renderErrorPage("Invalid OAuth callback (missing code or state)."), + 400 + ); + } + + try { + const result = await oauthProvider.handleCallback(code, state); + if (!result) { + return c.html( + renderErrorPage( + "Authentication failed. The link may have expired — request a new one." + ), + 401 + ); + } + + const { stateData, userInfo } = result; + + // Verify/establish identity mapping + const { linked, existingUserId } = await identityStore.linkIdentity( + oauthProvider.providerName, + userInfo.sub, + stateData.userId, + stateData.platform + ); + + if (!linked) { + logger.warn("OAuth identity mismatch", { + oauthSub: userInfo.sub, + sessionUserId: stateData.userId, + existingUserId, + }); + return c.html( + renderErrorPage( + "This OAuth account is already linked to a different user." + ), + 403 + ); + } + + // Load the session and set the cookie + const payload = await config.sessionStore.getSession( + stateData.sessionId + ); + if (!payload) { + return c.html( + renderErrorPage("Session expired. Please request a new link."), + 401 + ); + } + + setSettingsSessionCookie(c, stateData.sessionId, payload); + return c.redirect("/settings", 303); + } catch (err) { + logger.error("Settings OAuth callback failed", { error: err }); + return c.html( + renderErrorPage( + "Authentication failed due to a server error. Please try again." + ), + 500 + ); + } + }); + } + + // ========================================================================= + // GET /settings — HTML Settings Page + // ========================================================================= app.get("/settings", async (c) => { c.header("Referrer-Policy", "no-referrer"); c.header("Cache-Control", "no-store, max-age=0"); c.header("Pragma", "no-cache"); - const legacyToken = c.req.query("token"); - if (legacyToken) { - const payload = verifySettingsToken(legacyToken); + // Handle ?s= query param: validate session, set cookie, redirect clean + const querySessionId = c.req.query("s"); + if (querySessionId) { + const payload = await config.sessionStore.getSession(querySessionId); if (!payload) { clearSettingsSessionCookie(c); return c.html( @@ -164,11 +284,21 @@ export function createSettingsPageRoutes( ); } - setSettingsSessionCookie(c, legacyToken, payload); + // If OAuth configured, redirect through OAuth first + if (config.oauthProvider) { + const authUrl = await config.oauthProvider.startAuth( + payload.userId, + querySessionId, + payload.platform + ); + return c.redirect(authUrl, 303); + } + + setSettingsSessionCookie(c, querySessionId, payload); return c.redirect("/settings", 303); } - const payload = verifySettingsSession(c); + const payload = await verifySettingsSession(c); if (!payload) { return c.html(renderSessionBootstrapPage()); } @@ -177,7 +307,7 @@ export function createSettingsPageRoutes( let agentId = payload.agentId; if (!agentId && payload.channelId) { - // Channel-based token: try to resolve via existing binding + // Channel-based entry: try to resolve via existing binding const binding = await config.channelBindingService.getBinding( payload.platform, payload.channelId, @@ -329,7 +459,7 @@ export function createSettingsPageRoutes( // Disconnect an OAuth integration account app.post("/api/v1/integrations/oauth/disconnect", async (c) => { - const session = verifySettingsSession(c); + const session = await verifySettingsSession(c); if (!session) return c.json({ error: "Not authenticated" }, 401); const { agentId, integrationId, accountId } = await c.req.json<{ @@ -362,7 +492,7 @@ export function createSettingsPageRoutes( // Save an API key for an api-key integration app.post("/api/v1/integrations/apikey/save", async (c) => { - const session = verifySettingsSession(c); + const session = await verifySettingsSession(c); if (!session) return c.json({ error: "Not authenticated" }, 401); const { agentId, integrationId, apiKey } = await c.req.json<{ diff --git a/packages/gateway/src/services/core-services.ts b/packages/gateway/src/services/core-services.ts index 7c8c32e34..8d8dd2a97 100644 --- a/packages/gateway/src/services/core-services.ts +++ b/packages/gateway/src/services/core-services.ts @@ -25,6 +25,9 @@ import { } from "../auth/oauth/state-store"; import { ProviderCatalogService } from "../auth/provider-catalog"; import { AgentSettingsStore, AuthProfilesManager } from "../auth/settings"; +import { OAuthIdentityStore } from "../auth/settings/identity-store"; +import { SettingsOAuthProvider } from "../auth/settings/oauth-provider"; +import { AuthSessionStore } from "../auth/settings/session-store"; import { UserAgentsStore } from "../auth/user-agents-store"; import { ChannelBindingService } from "../channels"; import { registerBuiltInCommands } from "../commands/built-in-commands"; @@ -132,6 +135,13 @@ export class CoreServices { private agentMetadataStore?: AgentMetadataStore; private adminStatusCache?: AdminStatusCache; + // ============================================================================ + // Auth Session & OAuth + // ============================================================================ + private authSessionStore?: AuthSessionStore; + private settingsOAuthProvider?: SettingsOAuthProvider; + private oauthIdentityStore?: OAuthIdentityStore; + // ============================================================================ // Provider Catalog // ============================================================================ @@ -298,8 +308,18 @@ export class CoreServices { this.userAgentsStore = new UserAgentsStore(redisClient); this.agentMetadataStore = new AgentMetadataStore(redisClient); this.adminStatusCache = new AdminStatusCache(redisClient); + + // Auth session store (replaces encrypted tokens for settings, integration init) + this.authSessionStore = new AuthSessionStore(redisClient); + this.oauthIdentityStore = new OAuthIdentityStore(redisClient); + // Settings OAuth provider (optional — configured via SETTINGS_OAUTH_* env vars) + this.settingsOAuthProvider = SettingsOAuthProvider.fromEnv( + redisClient, + this.config.mcp.publicGatewayUrl + ); + logger.info( - "✅ Agent settings, channel binding, user agents & metadata stores initialized" + `✅ Agent settings, channel binding, user agents & metadata stores initialized (settings OAuth: ${this.settingsOAuthProvider ? "enabled" : "disabled"})` ); } @@ -685,10 +705,17 @@ export class CoreServices { const callbackUrl = `${this.config.mcp.publicGatewayUrl}/api/v1/auth/integration/callback`; + if (!this.authSessionStore) { + throw new Error( + "Auth session store must be initialized before integration services" + ); + } + this.integrationOAuthModule = new IntegrationOAuthModule( this.integrationConfigService, this.integrationCredentialStore, mcpOAuthStateStore, + this.authSessionStore, this.config.mcp.publicGatewayUrl, callbackUrl, this.queue @@ -879,4 +906,18 @@ export class CoreServices { getIntegrationOAuthModule(): IntegrationOAuthModule | undefined { return this.integrationOAuthModule; } + + getAuthSessionStore(): AuthSessionStore { + if (!this.authSessionStore) + throw new Error("Auth session store not initialized"); + return this.authSessionStore; + } + + getSettingsOAuthProvider(): SettingsOAuthProvider | undefined { + return this.settingsOAuthProvider; + } + + getOAuthIdentityStore(): OAuthIdentityStore | undefined { + return this.oauthIdentityStore; + } } From ef124b521ccde5e11ffffe04a6f66ebdfab726f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 19:50:58 +0000 Subject: [PATCH 4/6] fix(auth): update bootstrap page to use session ID instead of token The session bootstrap page now reads the `s` hash param (matching buildSessionUrl) and sends `sessionId` to POST /settings/session. Falls back to legacy `st` and `token` params for backward compat. https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- .../src/routes/public/settings-page/index.ts | 284 +++++++++--------- 1 file changed, 142 insertions(+), 142 deletions(-) diff --git a/packages/gateway/src/routes/public/settings-page/index.ts b/packages/gateway/src/routes/public/settings-page/index.ts index 5b2b8464b..c635567f2 100644 --- a/packages/gateway/src/routes/public/settings-page/index.ts +++ b/packages/gateway/src/routes/public/settings-page/index.ts @@ -5,8 +5,8 @@ import type { AgentMetadata } from "../../../auth/agent-metadata-store"; import type { AgentSettings } from "../../../auth/settings"; import { - SETTINGS_TOKEN_HASH_PARAM, - type SettingsTokenPayload, + SETTINGS_TOKEN_HASH_PARAM, + type SettingsTokenPayload, } from "../../../auth/settings/token-service"; import type { ModelOption } from "../../../modules/module-system"; import { settingsPageCSS } from "../settings-page-styles"; @@ -14,138 +14,138 @@ import { escapeHtml, formatUserId, getPlatformDisplay } from "./utils"; let settingsPageJS = ""; try { - const bundle = require("../settings-page-bundle"); - settingsPageJS = bundle.settingsPageJS; + const bundle = require("../settings-page-bundle"); + settingsPageJS = bundle.settingsPageJS; } catch { - settingsPageJS = - 'document.getElementById("app").textContent = "Bundle not built. Run: bun run scripts/build-settings.ts";'; + settingsPageJS = + 'document.getElementById("app").textContent = "Bundle not built. Run: bun run scripts/build-settings.ts";'; } export interface ProviderMeta { - id: string; - name: string; - iconUrl: string; - authType: "oauth" | "device-code" | "api-key"; - supportedAuthTypes: ("oauth" | "device-code" | "api-key")[]; - apiKeyInstructions: string; - apiKeyPlaceholder: string; - catalogDescription?: string; + id: string; + name: string; + iconUrl: string; + authType: "oauth" | "device-code" | "api-key"; + supportedAuthTypes: ("oauth" | "device-code" | "api-key")[]; + apiKeyInstructions: string; + apiKeyPlaceholder: string; + catalogDescription?: string; } export interface SettingsPageOptions { - providers?: ProviderMeta[]; - catalogProviders?: ProviderMeta[]; - providerModelOptions?: Record; - showSwitcher?: boolean; - agents?: (AgentMetadata & { channelCount: number })[]; - agentName?: string; - agentDescription?: string; - hasChannelId?: boolean; - systemSkills?: import("@lobu/core").SkillConfig[]; - integrationStatus?: Record< - string, - { - connected: boolean; - accounts: { accountId: string; grantedScopes: string[] }[]; - availableScopes: string[]; - } - >; + providers?: ProviderMeta[]; + catalogProviders?: ProviderMeta[]; + providerModelOptions?: Record; + showSwitcher?: boolean; + agents?: (AgentMetadata & { channelCount: number })[]; + agentName?: string; + agentDescription?: string; + hasChannelId?: boolean; + systemSkills?: import("@lobu/core").SkillConfig[]; + integrationStatus?: Record< + string, + { + connected: boolean; + accounts: { accountId: string; grantedScopes: string[] }[]; + availableScopes: string[]; + } + >; } export function renderSettingsPage( - payload: SettingsTokenPayload, - settings: AgentSettings | null, - options?: SettingsPageOptions + payload: SettingsTokenPayload, + settings: AgentSettings | null, + options?: SettingsPageOptions, ): string { - const s: Partial = settings || {}; - const providers: ProviderMeta[] = options?.providers ?? []; - const catalogProviders: ProviderMeta[] = options?.catalogProviders ?? []; - const providerModelOptions: Record = - options?.providerModelOptions || {}; - const providerOrder = providers.map((p) => p.id); - - const showSwitcher = options?.showSwitcher ?? false; - const agents = options?.agents ?? []; - const agentName = options?.agentName ?? ""; - const agentDescription = options?.agentDescription ?? ""; - const hasChannelId = options?.hasChannelId ?? false; - const initialNixPackages = (() => { - const existing = s.nixConfig?.packages || []; - const prefill = payload.prefillNixPackages || []; - return Array.from( - new Set( - [...existing, ...prefill] - .map((pkg) => (typeof pkg === "string" ? pkg.trim() : "")) - .filter(Boolean) - ) - ); - })(); - - const initialState = { - agentId: payload.agentId, - PROVIDERS: Object.fromEntries( - providers.map((p) => [ - p.id, - { - name: p.name, - authType: p.authType, - supportedAuthTypes: p.supportedAuthTypes, - apiKeyInstructions: p.apiKeyInstructions, - apiKeyPlaceholder: p.apiKeyPlaceholder, - }, - ]) - ), - providerOrder, - providerModels: providerModelOptions, - catalogProviders: catalogProviders.map((p) => ({ - id: p.id, - name: p.name, - iconUrl: p.iconUrl, - authType: p.authType, - supportedAuthTypes: p.supportedAuthTypes, - apiKeyInstructions: p.apiKeyInstructions, - apiKeyPlaceholder: p.apiKeyPlaceholder, - })), - initialSkills: [ - ...(options?.systemSkills || []), - ...(s.skillsConfig?.skills || []), - ], - initialMcpServers: s.mcpServers || {}, - prefillSkills: payload.prefillSkills || [], - prefillMcpServers: payload.prefillMcpServers || [], - prefillGrants: payload.prefillGrants || [], - prefillNixPackages: payload.prefillNixPackages || [], - prefillEnvVars: payload.prefillEnvVars || [], - initialNixPackages, - agentName, - agentDescription, - hasChannelId, - verboseLogging: !!s.verboseLogging, - identityMd: s.identityMd || "", - soulMd: s.soulMd || "", - userMd: s.userMd || "", - // Additional fields for Preact client - platform: payload.platform, - userId: payload.userId, - channelId: payload.channelId, - teamId: payload.teamId, - message: payload.message, - showSwitcher, - agents: agents.map((a) => ({ - agentId: a.agentId, - name: a.name, - isWorkspaceAgent: a.isWorkspaceAgent, - channelCount: a.channelCount, - description: a.description, - })), - hasNoProviders: providers.length === 0, - providerIconUrls: Object.fromEntries( - providers.map((p) => [p.id, p.iconUrl]) - ), - integrationStatus: options?.integrationStatus ?? {}, - }; - - return ` + const s: Partial = settings || {}; + const providers: ProviderMeta[] = options?.providers ?? []; + const catalogProviders: ProviderMeta[] = options?.catalogProviders ?? []; + const providerModelOptions: Record = + options?.providerModelOptions || {}; + const providerOrder = providers.map((p) => p.id); + + const showSwitcher = options?.showSwitcher ?? false; + const agents = options?.agents ?? []; + const agentName = options?.agentName ?? ""; + const agentDescription = options?.agentDescription ?? ""; + const hasChannelId = options?.hasChannelId ?? false; + const initialNixPackages = (() => { + const existing = s.nixConfig?.packages || []; + const prefill = payload.prefillNixPackages || []; + return Array.from( + new Set( + [...existing, ...prefill] + .map((pkg) => (typeof pkg === "string" ? pkg.trim() : "")) + .filter(Boolean), + ), + ); + })(); + + const initialState = { + agentId: payload.agentId, + PROVIDERS: Object.fromEntries( + providers.map((p) => [ + p.id, + { + name: p.name, + authType: p.authType, + supportedAuthTypes: p.supportedAuthTypes, + apiKeyInstructions: p.apiKeyInstructions, + apiKeyPlaceholder: p.apiKeyPlaceholder, + }, + ]), + ), + providerOrder, + providerModels: providerModelOptions, + catalogProviders: catalogProviders.map((p) => ({ + id: p.id, + name: p.name, + iconUrl: p.iconUrl, + authType: p.authType, + supportedAuthTypes: p.supportedAuthTypes, + apiKeyInstructions: p.apiKeyInstructions, + apiKeyPlaceholder: p.apiKeyPlaceholder, + })), + initialSkills: [ + ...(options?.systemSkills || []), + ...(s.skillsConfig?.skills || []), + ], + initialMcpServers: s.mcpServers || {}, + prefillSkills: payload.prefillSkills || [], + prefillMcpServers: payload.prefillMcpServers || [], + prefillGrants: payload.prefillGrants || [], + prefillNixPackages: payload.prefillNixPackages || [], + prefillEnvVars: payload.prefillEnvVars || [], + initialNixPackages, + agentName, + agentDescription, + hasChannelId, + verboseLogging: !!s.verboseLogging, + identityMd: s.identityMd || "", + soulMd: s.soulMd || "", + userMd: s.userMd || "", + // Additional fields for Preact client + platform: payload.platform, + userId: payload.userId, + channelId: payload.channelId, + teamId: payload.teamId, + message: payload.message, + showSwitcher, + agents: agents.map((a) => ({ + agentId: a.agentId, + name: a.name, + isWorkspaceAgent: a.isWorkspaceAgent, + channelCount: a.channelCount, + description: a.description, + })), + hasNoProviders: providers.length === 0, + providerIconUrls: Object.fromEntries( + providers.map((p) => [p.id, p.iconUrl]), + ), + integrationStatus: options?.integrationStatus ?? {}, + }; + + return ` @@ -168,10 +168,10 @@ export function renderSettingsPage( // ─── Picker Page ──────────────────────────────────────────────────────────── export function renderPickerPage( - payload: SettingsTokenPayload, - agents: (AgentMetadata & { channelCount: number })[] + payload: SettingsTokenPayload, + agents: (AgentMetadata & { channelCount: number })[], ): string { - return ` + return ` @@ -194,14 +194,14 @@ export function renderPickerPage( ${ - agents.length > 0 - ? ` + agents.length > 0 + ? `

Your Agents

${agents - .map( - (agent) => ` + .map( + (agent) => `

${escapeHtml(agent.name)}${agent.isWorkspaceAgent ? ' (workspace)' : ""}

@@ -212,15 +212,15 @@ ${agents class="ml-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-600 text-white hover:bg-slate-700 transition-all flex-shrink-0"> Select -
` - ) - .join("")} +
`, + ) + .join("")}
` - : `
+ : `

No agents yet. Create one below to get started.

` - } + }
@@ -351,7 +351,7 @@ ${agents // ─── Session Bootstrap Page ───────────────────────────────────────────────── export function renderSessionBootstrapPage(): string { - return ` + return ` @@ -428,13 +428,13 @@ export function renderSessionBootstrapPage(): string { } } - // Path B: Token-based authentication (existing flow) + // Path B: Session-based authentication (opaque session ID in URL hash) var hash = window.location.hash ? window.location.hash.slice(1) : ''; var params = new URLSearchParams(hash); - var token = params.get('${SETTINGS_TOKEN_HASH_PARAM}') || params.get('token'); + var sessionId = params.get('s') || params.get('${SETTINGS_TOKEN_HASH_PARAM}') || params.get('token'); - if (!token) { - showError('Missing settings link token. Request a new link with /configure.'); + if (!sessionId) { + showError('Missing settings link. Request a new link with /configure.'); return; } @@ -442,7 +442,7 @@ export function renderSessionBootstrapPage(): string { var resp = await fetch('/settings/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: token }) + body: JSON.stringify({ sessionId: sessionId }) }); if (!resp.ok) { @@ -464,7 +464,7 @@ export function renderSessionBootstrapPage(): string { // ─── Error Page ───────────────────────────────────────────────────────────── export function renderErrorPage(message: string): string { - return ` + return ` From b65365987d863158b7ba6a34eca11504e5aba289 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 20:09:14 +0000 Subject: [PATCH 5/6] refactor(auth): simplify to ?s= query params, remove all hash fragments and legacy token callers Consolidate all settings URL generation to use ?s= query params with Redis sessions. Remove hash fragment patterns (#s=, #st=, #token=), backward compatibility fallbacks, and all usage of generateSettingsToken, generateChannelSettingsToken, buildSettingsUrl, verifySettingsToken. Rename bootstrap page to Telegram-only since that's the sole use case. https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- .../src/auth/chatgpt/chatgpt-oauth-module.ts | 452 ++- packages/gateway/src/auth/settings/index.ts | 32 +- .../src/auth/settings/session-store.ts | 216 +- packages/gateway/src/cli/gateway.ts | 2218 +++++++------- .../gateway/src/commands/built-in-commands.ts | 165 +- packages/gateway/src/platform/link-buttons.ts | 56 +- .../src/routes/internal/settings-link.ts | 439 ++- .../gateway/src/routes/public/agent-config.ts | 2589 +++++++++-------- .../src/routes/public/agent-schedules.ts | 284 +- .../gateway/src/routes/public/channels.ts | 441 ++- .../src/routes/public/history-page/index.ts | 23 +- packages/gateway/src/routes/public/oauth.ts | 234 +- .../src/routes/public/settings-auth.ts | 84 +- .../src/routes/public/settings-page/index.ts | 93 +- .../gateway/src/routes/public/settings.ts | 1019 +++---- .../gateway/src/services/core-services.ts | 1717 +++++------ packages/gateway/src/slack/events/actions.ts | 825 +++--- packages/gateway/src/slack/events/messages.ts | 1592 +++++----- packages/gateway/src/whatsapp/auth-adapter.ts | 169 +- .../src/whatsapp/events/message-handler.ts | 2305 +++++++-------- 20 files changed, 7462 insertions(+), 7491 deletions(-) diff --git a/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts b/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts index 86c90057a..36d39ff50 100644 --- a/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts +++ b/packages/gateway/src/auth/chatgpt/chatgpt-oauth-module.ts @@ -1,21 +1,21 @@ import { createLogger } from "@lobu/core"; import type { ModelOption } from "../../modules/module-system"; +import { verifySettingsSession } from "../../routes/public/settings-auth"; import type { AgentMetadataStore } from "../agent-metadata-store"; import { BaseProviderModule } from "../base-provider-module"; import type { AgentSettingsStore } from "../settings/agent-settings-store"; import { - AuthProfilesManager, - createAuthProfileLabel, + AuthProfilesManager, + createAuthProfileLabel, } from "../settings/auth-profiles-manager"; -import { verifySettingsToken } from "../settings/token-service"; import type { UserAgentsStore } from "../user-agents-store"; import { ChatGPTDeviceCodeClient } from "./device-code-client"; const logger = createLogger("chatgpt-oauth-module"); interface ChatGPTOAuthModuleOptions { - userAgentsStore?: UserAgentsStore; - agentMetadataStore?: AgentMetadataStore; + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: AgentMetadataStore; } /** @@ -23,255 +23,251 @@ interface ChatGPTOAuthModuleOptions { * Stores the access token in auth profiles. */ export class ChatGPTOAuthModule extends BaseProviderModule { - private deviceCodeClient: ChatGPTDeviceCodeClient; - private userAgentsStore?: UserAgentsStore; - private agentMetadataStore?: AgentMetadataStore; + private deviceCodeClient: ChatGPTDeviceCodeClient; + private userAgentsStore?: UserAgentsStore; + private agentMetadataStore?: AgentMetadataStore; - constructor( - agentSettingsStore: AgentSettingsStore, - options: ChatGPTOAuthModuleOptions = {} - ) { - const authProfilesManager = new AuthProfilesManager(agentSettingsStore); - super( - { - providerId: "chatgpt", - providerDisplayName: "ChatGPT", - providerIconUrl: - "https://www.google.com/s2/favicons?domain=chatgpt.com&sz=128", - credentialEnvVarName: "OPENAI_API_KEY", - secretEnvVarNames: ["OPENAI_API_KEY"], - slug: "openai-codex", - upstreamBaseUrl: "https://chatgpt.com/backend-api", - baseUrlEnvVarName: "OPENAI_BASE_URL", - authType: "device-code", - supportedAuthTypes: ["device-code", "api-key"], - apiKeyInstructions: - 'Enter your OpenAI API key:', - apiKeyPlaceholder: "sk-...", - catalogDescription: "OpenAI's ChatGPT with device code authentication", - }, - authProfilesManager - ); - // Preserve existing module name - this.name = "chatgpt-oauth"; - this.deviceCodeClient = new ChatGPTDeviceCodeClient(); - this.userAgentsStore = options.userAgentsStore; - this.agentMetadataStore = options.agentMetadataStore; - } + constructor( + agentSettingsStore: AgentSettingsStore, + options: ChatGPTOAuthModuleOptions = {}, + ) { + const authProfilesManager = new AuthProfilesManager(agentSettingsStore); + super( + { + providerId: "chatgpt", + providerDisplayName: "ChatGPT", + providerIconUrl: + "https://www.google.com/s2/favicons?domain=chatgpt.com&sz=128", + credentialEnvVarName: "OPENAI_API_KEY", + secretEnvVarNames: ["OPENAI_API_KEY"], + slug: "openai-codex", + upstreamBaseUrl: "https://chatgpt.com/backend-api", + baseUrlEnvVarName: "OPENAI_BASE_URL", + authType: "device-code", + supportedAuthTypes: ["device-code", "api-key"], + apiKeyInstructions: + 'Enter your OpenAI API key:', + apiKeyPlaceholder: "sk-...", + catalogDescription: "OpenAI's ChatGPT with device code authentication", + }, + authProfilesManager, + ); + // Preserve existing module name + this.name = "chatgpt-oauth"; + this.deviceCodeClient = new ChatGPTDeviceCodeClient(); + this.userAgentsStore = options.userAgentsStore; + this.agentMetadataStore = options.agentMetadataStore; + } - getCliBackendConfig() { - return { - name: "codex", - command: "npx", - args: ["-y", "acpx@latest", "codex", "--quiet"], - modelArg: "--model", - }; - } + getCliBackendConfig() { + return { + name: "codex", + command: "npx", + args: ["-y", "acpx@latest", "codex", "--quiet"], + modelArg: "--model", + }; + } - async getModelOptions( - agentId: string, - _userId: string - ): Promise { - const token = await this.getCredential(agentId); - if (!token) return []; + async getModelOptions( + agentId: string, + _userId: string, + ): Promise { + const token = await this.getCredential(agentId); + if (!token) return []; - const response = await fetch("https://chatgpt.com/backend-api/models", { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - }).catch(() => null); + const response = await fetch("https://chatgpt.com/backend-api/models", { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }).catch(() => null); - if (!response || !response.ok) { - return []; - } + if (!response || !response.ok) { + return []; + } - const payload = (await response.json().catch(() => ({}))) as { - models?: Array<{ - slug?: string; - title?: string; - }>; - }; + const payload = (await response.json().catch(() => ({}))) as { + models?: Array<{ + slug?: string; + title?: string; + }>; + }; - return (payload.models || []) - .map((model) => { - const slug = model.slug?.trim(); - if (!slug) return null; - return { - value: `openai-codex/${slug}`, - label: model.title?.trim() || slug, - } satisfies ModelOption; - }) - .filter((item): item is ModelOption => Boolean(item)); - } + return (payload.models || []) + .map((model) => { + const slug = model.slug?.trim(); + if (!slug) return null; + return { + value: `openai-codex/${slug}`, + label: model.title?.trim() || slug, + } satisfies ModelOption; + }) + .filter((item): item is ModelOption => Boolean(item)); + } - /** - * Get authentication status for ChatGPT provider. - */ - async getAuthStatus( - _userId: string, - agentId: string - ): Promise< - Array<{ - id: string; - name: string; - isAuthenticated: boolean; - metadata?: Record; - }> - > { - try { - const hasCredentials = await this.hasCredentials(agentId); - const isAuthenticated = hasCredentials || this.hasSystemKey(); + /** + * Get authentication status for ChatGPT provider. + */ + async getAuthStatus( + _userId: string, + agentId: string, + ): Promise< + Array<{ + id: string; + name: string; + isAuthenticated: boolean; + metadata?: Record; + }> + > { + try { + const hasCredentials = await this.hasCredentials(agentId); + const isAuthenticated = hasCredentials || this.hasSystemKey(); - return [ - { - id: "chatgpt", - name: "ChatGPT", - isAuthenticated, - metadata: { - systemTokenAvailable: this.hasSystemKey(), - }, - }, - ]; - } catch (error) { - logger.error("Failed to get ChatGPT auth status", { error }); - return []; - } - } + return [ + { + id: "chatgpt", + name: "ChatGPT", + isAuthenticated, + metadata: { + systemTokenAvailable: this.hasSystemKey(), + }, + }, + ]; + } catch (error) { + logger.error("Failed to get ChatGPT auth status", { error }); + return []; + } + } - protected override setupRoutes(): void { - // Start device code flow - this.app.post("/start", async (c) => { - try { - const body = (await c.req.json().catch(() => ({}))) as { - agentId?: string; - token?: string; - }; - const agentId = body.agentId?.trim(); - const token = body.token?.trim(); + protected override setupRoutes(): void { + // Start device code flow + this.app.post("/start", async (c) => { + try { + const body = (await c.req.json().catch(() => ({}))) as { + agentId?: string; + }; + const agentId = body.agentId?.trim(); - if (!agentId || !token) { - return c.json({ error: "Missing agentId or token" }, 401); - } + if (!agentId) { + return c.json({ error: "Missing agentId" }, 400); + } - const authorized = await this.isAuthorizedForAgent(token, agentId); - if (!authorized) { - return c.json({ error: "Unauthorized" }, 401); - } + const authorized = await this.isAuthorizedForAgent(c, agentId); + if (!authorized) { + return c.json({ error: "Unauthorized" }, 401); + } - const result = await this.deviceCodeClient.requestDeviceCode(); - return c.json({ - userCode: result.userCode, - deviceAuthId: result.deviceAuthId, - interval: result.interval, - verificationUrl: "https://auth.openai.com/codex/device", - }); - } catch (error) { - logger.error("Failed to start device code flow", { error }); - return c.json({ error: "Failed to start device code flow" }, 500); - } - }); + const result = await this.deviceCodeClient.requestDeviceCode(); + return c.json({ + userCode: result.userCode, + deviceAuthId: result.deviceAuthId, + interval: result.interval, + verificationUrl: "https://auth.openai.com/codex/device", + }); + } catch (error) { + logger.error("Failed to start device code flow", { error }); + return c.json({ error: "Failed to start device code flow" }, 500); + } + }); - // Poll for token - this.app.post("/poll", async (c) => { - try { - const body = (await c.req.json().catch(() => ({}))) as { - deviceAuthId?: string; - userCode?: string; - agentId?: string; - token?: string; - }; - const deviceAuthId = body.deviceAuthId?.trim(); - const userCode = body.userCode?.trim(); - const agentId = body.agentId?.trim(); - const token = body.token?.trim(); + // Poll for token + this.app.post("/poll", async (c) => { + try { + const body = (await c.req.json().catch(() => ({}))) as { + deviceAuthId?: string; + userCode?: string; + agentId?: string; + }; + const deviceAuthId = body.deviceAuthId?.trim(); + const userCode = body.userCode?.trim(); + const agentId = body.agentId?.trim(); - if (!deviceAuthId || !userCode || !agentId || !token) { - return c.json( - { error: "Missing deviceAuthId, userCode, agentId, or token" }, - 400 - ); - } + if (!deviceAuthId || !userCode || !agentId) { + return c.json( + { error: "Missing deviceAuthId, userCode, or agentId" }, + 400, + ); + } - const authorized = await this.isAuthorizedForAgent(token, agentId); - if (!authorized) { - return c.json({ error: "Unauthorized" }, 401); - } + const authorized = await this.isAuthorizedForAgent(c, agentId); + if (!authorized) { + return c.json({ error: "Unauthorized" }, 401); + } - const result = await this.deviceCodeClient.pollForToken( - deviceAuthId, - userCode - ); + const result = await this.deviceCodeClient.pollForToken( + deviceAuthId, + userCode, + ); - if (!result) { - return c.json({ status: "pending" }); - } + if (!result) { + return c.json({ status: "pending" }); + } - await this.authProfilesManager.upsertProfile({ - agentId, - provider: this.providerId, - credential: result.accessToken, - authType: "device-code", - label: createAuthProfileLabel( - this.providerDisplayName, - result.accessToken, - result.accountId - ), - metadata: { - accountId: result.accountId, - refreshToken: result.refreshToken, - expiresAt: Date.now() + result.expiresIn * 1000, - }, - makePrimary: true, - }); + await this.authProfilesManager.upsertProfile({ + agentId, + provider: this.providerId, + credential: result.accessToken, + authType: "device-code", + label: createAuthProfileLabel( + this.providerDisplayName, + result.accessToken, + result.accountId, + ), + metadata: { + accountId: result.accountId, + refreshToken: result.refreshToken, + expiresAt: Date.now() + result.expiresIn * 1000, + }, + makePrimary: true, + }); - logger.info(`ChatGPT token saved for agent ${agentId}`); + logger.info(`ChatGPT token saved for agent ${agentId}`); - return c.json({ - status: "success", - accountId: result.accountId, - }); - } catch (error) { - logger.error("Failed to poll for token", { error }); - return c.json({ error: "Failed to poll for token" }, 500); - } - }); + return c.json({ + status: "success", + accountId: result.accountId, + }); + } catch (error) { + logger.error("Failed to poll for token", { error }); + return c.json({ error: "Failed to poll for token" }, 500); + } + }); - logger.info("ChatGPT auth routes configured"); - } + logger.info("ChatGPT auth routes configured"); + } - private async isAuthorizedForAgent( - token: string, - agentId: string - ): Promise { - const payload = verifySettingsToken(token); - if (!payload) { - return false; - } + private async isAuthorizedForAgent( + c: import("hono").Context, + agentId: string, + ): Promise { + const payload = await verifySettingsSession(c); + if (!payload) { + return false; + } - if (payload.agentId) { - return payload.agentId === agentId; - } + if (payload.agentId) { + return payload.agentId === agentId; + } - if (!this.userAgentsStore) { - logger.warn("UserAgentsStore unavailable for channel-based token auth"); - return false; - } + if (!this.userAgentsStore) { + logger.warn("UserAgentsStore unavailable for channel-based token auth"); + return false; + } - const userOwnsAgent = await this.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ); - if (userOwnsAgent) { - return true; - } + const userOwnsAgent = await this.userAgentsStore.ownsAgent( + payload.platform, + payload.userId, + agentId, + ); + if (userOwnsAgent) { + return true; + } - if (!this.agentMetadataStore) { - return false; - } + if (!this.agentMetadataStore) { + return false; + } - const metadata = await this.agentMetadataStore.getMetadata(agentId); - return metadata?.isWorkspaceAgent === true; - } + const metadata = await this.agentMetadataStore.getMetadata(agentId); + return metadata?.isWorkspaceAgent === true; + } } diff --git a/packages/gateway/src/auth/settings/index.ts b/packages/gateway/src/auth/settings/index.ts index 36f0a40c7..37183dc9e 100644 --- a/packages/gateway/src/auth/settings/index.ts +++ b/packages/gateway/src/auth/settings/index.ts @@ -1,28 +1,22 @@ export { type AgentSettings, AgentSettingsStore } from "./agent-settings-store"; export { - AuthProfilesManager, - createAuthProfileLabel, - type UpsertAuthProfileInput, + AuthProfilesManager, + createAuthProfileLabel, + type UpsertAuthProfileInput, } from "./auth-profiles-manager"; export { OAuthIdentityStore } from "./identity-store"; export { SettingsOAuthProvider } from "./oauth-provider"; export { - AuthSessionStore, - buildIntegrationInitUrl, - buildSessionUrl, + AuthSessionStore, + buildIntegrationInitUrl, + buildSessionUrl, } from "./session-store"; export { - buildSettingsUrl, - buildTelegramSettingsUrl, - formatSettingsTokenTtl, - generateChannelSettingsToken, - generateSettingsToken, - getSettingsTokenTtlMs, - type PrefillMcpServer, - type PrefillSkill, - type SettingsSessionPayload, - type SettingsSourceContext, - type SettingsTokenOptions, - type SettingsTokenPayload, - verifySettingsToken, + buildTelegramSettingsUrl, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, + type PrefillMcpServer, + type PrefillSkill, + type SettingsSessionPayload, + type SettingsSourceContext, } from "./token-service"; diff --git a/packages/gateway/src/auth/settings/session-store.ts b/packages/gateway/src/auth/settings/session-store.ts index 532f9342c..d2001d6aa 100644 --- a/packages/gateway/src/auth/settings/session-store.ts +++ b/packages/gateway/src/auth/settings/session-store.ts @@ -14,126 +14,120 @@ const logger = createLogger("auth-session-store"); * Key pattern: auth:session:{uuid} */ export class AuthSessionStore extends BaseRedisStore { - constructor(redis: Redis) { - super({ - redis, - keyPrefix: "auth:session", - loggerName: "auth-session-store", - }); - } - - /** - * Create a new session with the given payload. - * Returns an opaque session ID (URL-safe random string). - */ - async createSession( - payload: Omit, - ttlMs: number - ): Promise<{ sessionId: string; expiresAt: number }> { - const sessionId = randomBytes(32).toString("base64url"); - const expiresAt = Date.now() + ttlMs; - const fullPayload: SettingsSessionPayload = { ...payload, exp: expiresAt }; - - const ttlSeconds = Math.ceil(ttlMs / 1000); - const key = this.buildKey(sessionId); - await this.set(key, fullPayload, ttlSeconds); - - logger.info("Created auth session", { - sessionId: `${sessionId.substring(0, 8)}...`, - userId: payload.userId, - agentId: payload.agentId, - platform: payload.platform, - }); - - return { sessionId, expiresAt }; - } - - /** - * Look up a session by ID. - * Returns the payload if found and not expired, null otherwise. - */ - async getSession(sessionId: string): Promise { - const key = this.buildKey(sessionId); - const payload = await this.get(key); - if (!payload) return null; - - // Double-check expiry (Redis TTL is authoritative, but belt-and-suspenders) - if (Date.now() > payload.exp) { - logger.warn("Session expired (clock check)", { - sessionId: `${sessionId.substring(0, 8)}...`, - }); - await this.delete(key); - return null; - } - - return payload; - } - - /** - * Consume a session (one-time use). Returns payload and deletes. - * Used for integration init URLs and MCP login URLs. - */ - async consumeSession( - sessionId: string - ): Promise { - const key = this.buildKey(sessionId); - - // Atomic get+delete - const data = await this.redis.getdel(key); - if (!data) return null; - - try { - const payload = JSON.parse(data) as SettingsSessionPayload; - - if (Date.now() > payload.exp) { - logger.warn("Consumed session was expired", { - sessionId: `${sessionId.substring(0, 8)}...`, - }); - return null; - } - - return payload; - } catch (error) { - logger.error("Failed to parse session data", { error }); - return null; - } - } - - /** - * Delete a session explicitly. - */ - async deleteSession(sessionId: string): Promise { - const key = this.buildKey(sessionId); - await this.delete(key); - } - - override validate(value: SettingsSessionPayload): boolean { - return !!value.userId && !!value.platform && !!value.exp; - } + constructor(redis: Redis) { + super({ + redis, + keyPrefix: "auth:session", + loggerName: "auth-session-store", + }); + } + + /** + * Create a new session with the given payload. + * Returns an opaque session ID (URL-safe random string). + */ + async createSession( + payload: Omit, + ttlMs: number, + ): Promise<{ sessionId: string; expiresAt: number }> { + const sessionId = randomBytes(32).toString("base64url"); + const expiresAt = Date.now() + ttlMs; + const fullPayload: SettingsSessionPayload = { ...payload, exp: expiresAt }; + + const ttlSeconds = Math.ceil(ttlMs / 1000); + const key = this.buildKey(sessionId); + await this.set(key, fullPayload, ttlSeconds); + + logger.info("Created auth session", { + sessionId: `${sessionId.substring(0, 8)}...`, + userId: payload.userId, + agentId: payload.agentId, + platform: payload.platform, + }); + + return { sessionId, expiresAt }; + } + + /** + * Look up a session by ID. + * Returns the payload if found and not expired, null otherwise. + */ + async getSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + const payload = await this.get(key); + if (!payload) return null; + + // Double-check expiry (Redis TTL is authoritative, but belt-and-suspenders) + if (Date.now() > payload.exp) { + logger.warn("Session expired (clock check)", { + sessionId: `${sessionId.substring(0, 8)}...`, + }); + await this.delete(key); + return null; + } + + return payload; + } + + /** + * Consume a session (one-time use). Returns payload and deletes. + * Used for integration init URLs and MCP login URLs. + */ + async consumeSession( + sessionId: string, + ): Promise { + const key = this.buildKey(sessionId); + + // Atomic get+delete + const data = await this.redis.getdel(key); + if (!data) return null; + + try { + const payload = JSON.parse(data) as SettingsSessionPayload; + + if (Date.now() > payload.exp) { + logger.warn("Consumed session was expired", { + sessionId: `${sessionId.substring(0, 8)}...`, + }); + return null; + } + + return payload; + } catch (error) { + logger.error("Failed to parse session data", { error }); + return null; + } + } + + /** + * Delete a session explicitly. + */ + async deleteSession(sessionId: string): Promise { + const key = this.buildKey(sessionId); + await this.delete(key); + } + + override validate(value: SettingsSessionPayload): boolean { + return !!value.userId && !!value.platform && !!value.exp; + } } /** - * Build a settings URL with session ID. - * Session ID travels in the URL hash to avoid logging/referrer leaks. + * Build a settings URL with session ID as a query parameter. + * The GET /settings handler validates the session, sets a cookie, and redirects clean. */ -export function buildSessionUrl( - sessionId: string, - opts?: { useQueryParam?: boolean } -): string { - const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; - if (opts?.useQueryParam) { - return `${baseUrl}/settings?s=${encodeURIComponent(sessionId)}`; - } - return `${baseUrl}/settings#s=${encodeURIComponent(sessionId)}`; +export function buildSessionUrl(sessionId: string): string { + const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; + return `${baseUrl}/settings?s=${encodeURIComponent(sessionId)}`; } /** * Build an integration init URL with session ID. */ export function buildIntegrationInitUrl( - integrationId: string, - sessionId: string + integrationId: string, + sessionId: string, ): string { - const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; - return `${baseUrl}/api/v1/auth/integration/init/${integrationId}?s=${encodeURIComponent(sessionId)}`; + const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; + return `${baseUrl}/api/v1/auth/integration/init/${integrationId}?s=${encodeURIComponent(sessionId)}`; } diff --git a/packages/gateway/src/cli/gateway.ts b/packages/gateway/src/cli/gateway.ts index 95eddfd4b..b71c464a5 100644 --- a/packages/gateway/src/cli/gateway.ts +++ b/packages/gateway/src/cli/gateway.ts @@ -24,697 +24,689 @@ let httpServer: Server | null = null; * Setup Hono server with all routes on port 8080 */ function setupServer( - secretProxy: any, - workerGateway: any, - mcpProxy: any, - interactionService?: any, - platformRegistry?: any, - coreServices?: any, - telegramPlatform?: TelegramPlatform | null, - slackExpressApp?: any + secretProxy: any, + workerGateway: any, + mcpProxy: any, + interactionService?: any, + platformRegistry?: any, + coreServices?: any, + telegramPlatform?: TelegramPlatform | null, + slackExpressApp?: any, ) { - if (httpServer) return; - - const app = new OpenAPIHono(); - - // Global middleware - app.use( - "*", - secureHeaders({ - xFrameOptions: "DENY", - xContentTypeOptions: "nosniff", - referrerPolicy: "strict-origin-when-cross-origin", - strictTransportSecurity: "max-age=63072000; includeSubDomains", - contentSecurityPolicy: { - defaultSrc: ["'self'"], - scriptSrc: [ - "'self'", - "'unsafe-inline'", - "'unsafe-eval'", - "https://cdn.jsdelivr.net", - ], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'", "ws:", "wss:"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - }, - }) - ); - app.use( - "*", - cors({ - origin: process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(",") - : "*", - }) - ); - - // Health endpoints - app.get("/health", (c) => { - const mode = - process.env.LOBU_MODE || - (process.env.DEPLOYMENT_MODE === "docker" ? "local" : "cloud"); - - return c.json({ - status: "ok", - mode, - version: process.env.npm_package_version || "2.3.0", - timestamp: new Date().toISOString(), - publicGatewayUrl: - coreServices?.getPublicGatewayUrl?.() || process.env.PUBLIC_GATEWAY_URL, - capabilities: { - agents: ["claude"], - streaming: true, - toolApproval: true, - }, - wsUrl: `ws://localhost:8080/ws`, - secretProxy: !!secretProxy, - }); - }); - - app.get("/ready", (c) => c.json({ ready: true })); - - // Prometheus metrics endpoint - app.get("/metrics", async (c) => { - const metricsAuthToken = process.env.METRICS_AUTH_TOKEN; - if (metricsAuthToken) { - const authHeader = c.req.header("Authorization"); - if (authHeader !== `Bearer ${metricsAuthToken}`) { - return c.text("Unauthorized", 401); - } - } - const { getMetricsText } = await import("../metrics/prometheus"); - c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); - return c.text(getMetricsText()); - }); - - // Secret injection proxy (Hono) - if (secretProxy) { - app.route("/api/proxy", secretProxy.getApp()); - logger.info("Secret proxy enabled at :8080/api/proxy"); - } - - // Worker Gateway routes (Hono) - if (workerGateway) { - app.route("/worker", workerGateway.getApp()); - logger.info("Worker gateway routes enabled at :8080/worker/*"); - } - - // Register module endpoints - const { moduleRegistry: coreModuleRegistry } = require("@lobu/core"); - if (coreModuleRegistry.registerHonoEndpoints) { - coreModuleRegistry.registerHonoEndpoints(app); - } else { - // Create express-like adapter for module registry - const expressApp = createExpressAdapter(app); - coreModuleRegistry.registerEndpoints(expressApp); - } - logger.info("Module endpoints registered"); - - // MCP proxy routes (Hono) - if (mcpProxy) { - // Handle root path requests with X-Mcp-Id header - app.all("/", async (c, next) => { - if (mcpProxy.isMcpRequest(c)) { - // Forward to MCP proxy - need to handle directly since it's at root - return mcpProxy.getApp().fetch(c.req.raw); - } - return next(); - }); - // Mount MCP proxy at /mcp/* - app.route("/mcp", mcpProxy.getApp()); - logger.info("MCP proxy routes enabled at :8080/mcp/*"); - } - - // Telegram webhook route - const telegramWebhookRoute = telegramPlatform?.getWebhookRoute(); - if (telegramWebhookRoute) { - app.route(TELEGRAM_WEBHOOK_PATH, telegramWebhookRoute); - logger.info( - `Telegram webhook route enabled at :8080${TELEGRAM_WEBHOOK_PATH}` - ); - } - - // Slack OAuth routes for multi-workspace distribution - if (platformRegistry) { - const slackAdapter = platformRegistry.get?.("slack"); - const slackInstallationStore = slackAdapter?.getInstallationStore?.(); - const slackClientId = process.env.SLACK_CLIENT_ID; - const slackClientSecret = process.env.SLACK_CLIENT_SECRET; - const publicGatewayUrl = process.env.PUBLIC_GATEWAY_URL; - - if ( - slackInstallationStore && - slackClientId && - slackClientSecret && - publicGatewayUrl - ) { - const { createSlackOAuthRoutes } = require("../slack/oauth-routes"); - const redis = coreServices?.getQueue?.()?.getRedisClient?.(); - if (redis) { - const slackOAuthRouter = createSlackOAuthRoutes({ - clientId: slackClientId, - clientSecret: slackClientSecret, - installationStore: slackInstallationStore, - redis, - publicGatewayUrl, - }); - app.route("/slack", slackOAuthRouter); - logger.info( - "Slack OAuth routes enabled at :8080/slack/install and :8080/slack/oauth_callback" - ); - } - } - } - - // File routes (already Hono) - uses platform registry for per-platform file handling - if (platformRegistry) { - const { createFileRoutes } = require("../routes/internal/files"); - const fileRouter = createFileRoutes(platformRegistry); - app.route("/internal/files", fileRouter); - logger.info("File routes enabled at :8080/internal/files/*"); - } - - // History routes (already Hono) - { - const { createHistoryRoutes } = require("../routes/internal/history"); - // Pass Slack installation store for multi-workspace token resolution - const slackAdapter = platformRegistry?.get?.("slack"); - const slackInstallationStore = slackAdapter?.getInstallationStore?.(); - const historyRouter = createHistoryRoutes(slackInstallationStore); - app.route("/internal", historyRouter); - logger.info("History routes enabled at :8080/internal/history"); - } - - // Schedule routes (worker scheduling endpoints) - if (coreServices) { - const scheduledWakeupService = coreServices.getScheduledWakeupService(); - if (scheduledWakeupService) { - const { createScheduleRoutes } = require("../routes/internal/schedule"); - const scheduleRouter = createScheduleRoutes(scheduledWakeupService); - app.route("", scheduleRouter); - logger.info("Schedule routes enabled at :8080/internal/schedule"); - } - } - - // Initialize auth session store for settings-auth module - if (coreServices) { - const { setSessionStore } = require("../routes/public/settings-auth"); - setSessionStore(coreServices.getAuthSessionStore()); - logger.info("Auth session store wired into settings-auth"); - } - - // Settings link routes (worker can generate settings links for users) - { - const { - createSettingsLinkRoutes, - } = require("../routes/internal/settings-link"); - const settingsLinkRouter = createSettingsLinkRoutes( - coreServices!.getAuthSessionStore(), - interactionService, - coreServices?.getGrantStore() - ); - app.route("", settingsLinkRouter); - logger.info("Settings link routes enabled at :8080/internal/settings-link"); - } - - // MCP login routes (worker can trigger MCP OAuth login for users) - if (coreServices?.getMcpOAuthModule()) { - const { createMcpLoginRoutes } = require("../routes/internal/mcp-login"); - const mcpLoginRouter = createMcpLoginRoutes( - coreServices.getMcpOAuthModule(), - interactionService - ); - app.route("", mcpLoginRouter); - logger.info("MCP login routes enabled at :8080/internal/mcp-login"); - } - - // Integrations discovery routes (unified skills + MCP search for workers) - { - const { - createIntegrationsDiscoveryRoutes, - } = require("../routes/internal/integrations-discovery"); - const { SkillRegistryCoordinator } = require("../services/skill-registry"); - const skillRegistryCoordinator = new SkillRegistryCoordinator(); - const integrationsDiscoveryRouter = createIntegrationsDiscoveryRoutes({ - coordinator: skillRegistryCoordinator, - agentSettingsStore: coreServices?.getAgentSettingsStore(), - integrationConfigService: coreServices?.getIntegrationConfigService(), - integrationCredentialStore: coreServices?.getIntegrationCredentialStore(), - }); - app.route("", integrationsDiscoveryRouter); - logger.info( - "Integrations discovery routes enabled at :8080/internal/integrations/*" - ); - } - - // Audio routes (TTS synthesis for workers) - if (coreServices) { - const transcriptionService = coreServices.getTranscriptionService(); - if (transcriptionService) { - const { createAudioRoutes } = require("../routes/internal/audio"); - const audioRouter = createAudioRoutes(transcriptionService); - app.route("", audioRouter); - logger.info("Audio routes enabled at :8080/internal/audio/*"); - } - } - - // Interaction routes (already Hono) - if (interactionService) { - const { - createInteractionRoutes, - } = require("../routes/internal/interactions"); - const internalRouter = createInteractionRoutes(interactionService); - app.route("", internalRouter); - logger.info("Internal interaction routes enabled"); - } - - // Messaging routes (already Hono) - if (platformRegistry) { - const { createMessagingRoutes } = require("../routes/public/messaging"); - const messagingRouter = createMessagingRoutes(platformRegistry); - app.route("", messagingRouter); - logger.info("Messaging routes enabled at :8080/api/v1/messaging/send"); - } - - // Agent API routes (direct API access) - if (coreServices) { - const queueProducer = coreServices.getQueueProducer(); - const sessionMgr = coreServices.getSessionManager(); - const interactionSvc = coreServices.getInteractionService(); - const publicUrl = coreServices.getPublicGatewayUrl(); - - if (queueProducer && sessionMgr && interactionSvc) { - // Agent API (Hono with OpenAPI docs) - const { createAgentApi } = require("../routes/public/agent"); - const agentApi = createAgentApi(queueProducer, sessionMgr, publicUrl); - app.route("", agentApi); - logger.info( - "Agent API enabled at :8080/api/v1/agents/* with docs at :8080/api/docs" - ); - } - } - - if (coreServices) { - // Mount OAuth modules under unified auth router - const authRouter = new OpenAPIHono(); - const registeredProviders: string[] = []; - - // Dynamically mount model provider auth routes - const providerModules = getModelProviderModules(); - - // Shared save-key + logout handlers (parameterized by :provider) - const authProfilesManager = coreServices.getAuthProfilesManager(); - if (authProfilesManager) { - const { verifySettingsToken } = require("../auth/settings/token-service"); - const { - verifySettingsSession, - } = require("../routes/public/settings-auth"); - const { - createAuthProfileLabel, - } = require("../auth/settings/auth-profiles-manager"); - const agentMetadataStore = coreServices.getAgentMetadataStore(); - const userAgentsStore = coreServices.getUserAgentsStore(); - - /** Verify token or session cookie authorizes access to the given agentId */ - const verifyProviderAuth = async ( - c: any, - agentId: string - ): Promise => { - // Try explicit token first (query param or body) - const body = c.__parsedBody; - const queryToken = c.req.query("token"); - const authToken = - typeof body?.token === "string" ? body.token : queryToken; - const payload = authToken - ? verifySettingsToken(authToken) - : verifySettingsSession(c); - if (!payload) return false; - - // Agent-based token: must match exactly - if (payload.agentId) return payload.agentId === agentId; - - // Channel-based token: check user-agent association or metadata owner - if (userAgentsStore) { - const owns = await userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ); - if (owns) return true; - } - if (agentMetadataStore) { - const metadata = await agentMetadataStore.getMetadata(agentId); - const isOwner = - metadata?.owner?.platform === payload.platform && - metadata?.owner?.userId === payload.userId; - if (isOwner) { - // Reconcile missing index - userAgentsStore - ?.addAgent(payload.platform, payload.userId, agentId) - .catch(() => { - /* best-effort reconciliation */ - }); - return true; - } - if (metadata?.isWorkspaceAgent) return true; - } - return false; - }; - - authRouter.post("/:provider/save-key", async (c: any) => { - try { - const providerId = c.req.param("provider"); - const mod = getModelProviderModules().find( - (m) => m.providerId === providerId - ); - if (!mod) return c.json({ error: "Unknown provider" }, 404); - - const body = await c.req.json(); - c.__parsedBody = body; - const { agentId, apiKey } = body; - if (!agentId || !apiKey) { - return c.json({ error: "Missing agentId or apiKey" }, 400); - } - - if (!(await verifyProviderAuth(c, agentId))) { - return c.json({ error: "Unauthorized" }, 401); - } - - await authProfilesManager.upsertProfile({ - agentId, - provider: providerId, - credential: apiKey, - authType: "api-key", - label: createAuthProfileLabel(mod.providerDisplayName, apiKey), - makePrimary: true, - }); - - return c.json({ success: true }); - } catch (error) { - logger.error("Failed to save API key", { error }); - return c.json({ error: "Failed to save API key" }, 500); - } - }); - - authRouter.post("/:provider/logout", async (c: any) => { - try { - const providerId = c.req.param("provider"); - const mod = getModelProviderModules().find( - (m) => m.providerId === providerId - ); - if (!mod) return c.json({ error: "Unknown provider" }, 404); - - const body = await c.req.json().catch(() => ({})); - c.__parsedBody = body; - const agentId = body.agentId || c.req.query("agentId"); - - if (!agentId) { - return c.json({ error: "Missing agentId" }, 400); - } - - if (!(await verifyProviderAuth(c, agentId))) { - return c.json({ error: "Unauthorized" }, 401); - } - - await authProfilesManager.deleteProviderProfiles( - agentId, - providerId, - body.profileId - ); - - return c.json({ success: true }); - } catch (error) { - logger.error("Failed to logout", { error }); - return c.json({ error: "Failed to logout" }, 500); - } - }); - } - - for (const mod of providerModules) { - if (mod.getApp) { - authRouter.route(`/${mod.providerId}`, mod.getApp()); - registeredProviders.push(mod.providerId); - } - } - - const mcpOAuthModule = coreServices.getMcpOAuthModule(); - if (mcpOAuthModule) { - authRouter.route("/mcp", mcpOAuthModule.getApp()); - registeredProviders.push("mcp"); - } - - // Integration OAuth + internal routes - const integrationConfigService = coreServices.getIntegrationConfigService(); - const integrationCredentialStore = - coreServices.getIntegrationCredentialStore(); - const integrationOAuthModule = coreServices.getIntegrationOAuthModule(); - - if ( - integrationConfigService && - integrationCredentialStore && - integrationOAuthModule - ) { - // Internal routes for workers (list, connect, disconnect) - const { createIntegrationRoutes } = require("../auth/integration/routes"); - const publicGatewayUrl = coreServices.getPublicGatewayUrl(); - const integrationRouter = createIntegrationRoutes( - integrationConfigService, - integrationCredentialStore, - integrationOAuthModule, - publicGatewayUrl, - interactionService, - coreServices.getAgentSettingsStore() - ); - app.route("", integrationRouter); - - // API proxy (credential injection + forwarding) - const { - createIntegrationApiProxy, - } = require("../auth/integration/api-proxy"); - const apiProxyRouter = createIntegrationApiProxy( - integrationConfigService, - integrationCredentialStore - ); - app.route("", apiProxyRouter); - - // OAuth routes (public, for user browser redirects) - authRouter.route("/integration", integrationOAuthModule.getApp()); - registeredProviders.push("integration"); - - logger.info( - "Integration routes enabled at :8080/internal/integrations/*, :8080/api/v1/auth/integration/*" - ); - } - - // Get shared dependencies (needed before mounting auth router) - const agentSettingsStore = coreServices.getAgentSettingsStore(); - const claudeOAuthStateStore = coreServices.getClaudeOAuthStateStore(); - const scheduledWakeupService = coreServices.getScheduledWakeupService(); - - // Build provider stores and overrides dynamically from registered modules - const providerStores: Record< - string, - { hasCredentials(agentId: string): Promise } - > = {}; - const providerConnectedOverrides: Record = {}; - for (const mod of providerModules) { - providerStores[mod.providerId] = mod; - providerConnectedOverrides[mod.providerId] = mod.hasSystemKey(); - } - - // Settings HTML page - if (agentSettingsStore) { - const { createSettingsPageRoutes } = require("../routes/public/settings"); - const settingsPageRouter = createSettingsPageRoutes({ - agentSettingsStore, - userAgentsStore: coreServices.getUserAgentsStore(), - agentMetadataStore: coreServices.getAgentMetadataStore(), - channelBindingService: coreServices.getChannelBindingService(), - sessionStore: coreServices.getAuthSessionStore(), - oauthProvider: coreServices.getSettingsOAuthProvider(), - identityStore: coreServices.getOAuthIdentityStore(), - integrationConfigService: coreServices.getIntegrationConfigService(), - integrationCredentialStore: - coreServices.getIntegrationCredentialStore(), - connectionManager: coreServices - .getWorkerGateway() - ?.getConnectionManager(), - systemSkillsService: coreServices.getSystemSkillsService(), - }); - app.route("", settingsPageRouter); - logger.info("Settings HTML page enabled at :8080/settings"); - } - - // Landing page (docs + integrations) - { - const { createLandingRoutes } = require("../routes/public/landing"); - const landingRouter = createLandingRoutes({ - publicGatewayUrl: coreServices.getPublicGatewayUrl(), - githubUrl: "https://github.com/lobu-ai/lobu", - }); - app.route("", landingRouter); - logger.info("Landing page enabled at :8080/"); - } - - // Agent history routes (proxy to worker HTTP server) - { - const connectionManager = coreServices - .getWorkerGateway() - ?.getConnectionManager(); - if (connectionManager) { - const { - createAgentHistoryRoutes, - } = require("../routes/public/agent-history"); - const agentHistoryRouter = createAgentHistoryRoutes({ - connectionManager, - }); - app.route("/api/v1/agents/:agentId/history", agentHistoryRouter); - logger.info( - "Agent history routes enabled at :8080/api/v1/agents/{agentId}/history/*" - ); - - // History HTML page - const { renderHistoryPage } = require("../routes/public/history-page"); - const { - verifySettingsSession, - } = require("../routes/public/settings-auth"); - app.get("/agent/:agentId/history", (c: any) => { - const session = verifySettingsSession(c); - if (!session) { - return c.redirect("/settings"); - } - const agentId = c.req.param("agentId"); - return c.html(renderHistoryPage(agentId)); - }); - logger.info("History page enabled at :8080/agent/{agentId}/history"); - } - } - - // Agent config routes (/api/v1/agents/{id}/config) - if (agentSettingsStore) { - const { - createAgentConfigRoutes, - } = require("../routes/public/agent-config"); - - const agentConfigRouter = createAgentConfigRoutes({ - agentSettingsStore, - userAgentsStore: coreServices.getUserAgentsStore(), - agentMetadataStore: coreServices.getAgentMetadataStore(), - queue: coreServices.getQueue(), - providerStores: - Object.keys(providerStores).length > 0 ? providerStores : undefined, - providerConnectedOverrides, - providerCatalogService: coreServices.getProviderCatalogService(), - authProfilesManager: coreServices.getAuthProfilesManager(), - connectionManager: coreServices - .getWorkerGateway() - ?.getConnectionManager(), - grantStore: coreServices.getGrantStore(), - }); - app.route("/api/v1/agents/:agentId/config", agentConfigRouter); - logger.info( - "Agent config routes enabled at :8080/api/v1/agents/{id}/config" - ); - } - - // Agent schedules routes (/api/v1/agents/{id}/schedules) - { - const { - createAgentSchedulesRoutes, - } = require("../routes/public/agent-schedules"); - const agentSchedulesRouter = createAgentSchedulesRoutes({ - scheduledWakeupService, - userAgentsStore: coreServices.getUserAgentsStore(), - agentMetadataStore: coreServices.getAgentMetadataStore(), - }); - app.route("/api/v1/agents/:agentId/schedules", agentSchedulesRouter); - logger.info( - "Agent schedules routes enabled at :8080/api/v1/agents/{id}/schedules" - ); - } - - // Integrations routes (unified skills + MCP registry) - { - const { - createIntegrationsRoutes, - } = require("../routes/public/integrations"); - const integrationsRouter = createIntegrationsRoutes(); - app.route("/api/v1/integrations", integrationsRouter); - logger.info("Integrations routes enabled at :8080/api/v1/integrations/*"); - } - - // OAuth routes (mounted under unified auth router) - if (agentSettingsStore) { - const { createOAuthRoutes } = require("../routes/public/oauth"); - const { ClaudeOAuthClient } = require("../auth/oauth/claude-client"); - const claudeOAuthClient = new ClaudeOAuthClient(); - const oauthRouter = createOAuthRoutes({ - providerStores: - Object.keys(providerStores).length > 0 ? providerStores : undefined, - oauthClients: { claude: claudeOAuthClient }, - oauthStateStore: claudeOAuthStateStore, - }); - authRouter.route("", oauthRouter); - registeredProviders.push("oauth"); - } - - // Mount unified auth router (includes provider modules + OAuth) - if (registeredProviders.length > 0) { - app.route("/api/v1/auth", authRouter); - logger.info( - `Auth routes enabled at :8080/api/v1/auth/* for: ${registeredProviders.join(", ")}` - ); - } - - // Channel binding routes (mount under agent API) - const channelBindingService = coreServices.getChannelBindingService(); - if (channelBindingService) { - const { - createChannelBindingRoutes, - } = require("../routes/public/channels"); - const channelBindingRouter = createChannelBindingRoutes({ - channelBindingService, - userAgentsStore: coreServices.getUserAgentsStore(), - agentMetadataStore: coreServices.getAgentMetadataStore(), - }); - // Mount as a sub-router under /api/v1/agents/:agentId/channels - app.route("/api/v1/agents/:agentId/channels", channelBindingRouter); - logger.info( - "Channel binding routes enabled at :8080/api/v1/agents/{agentId}/channels/*" - ); - } - - // Agent management routes (separate from Agent API's /api/v1/agents) - { - const userAgentsStore = coreServices.getUserAgentsStore(); - const agentMetadataStore = coreServices.getAgentMetadataStore(); - const { createAgentRoutes } = require("../routes/public/agents"); - const agentManagementRouter = createAgentRoutes({ - userAgentsStore, - agentMetadataStore, - agentSettingsStore, - channelBindingService, - }); - app.route("/api/v1/manage/agents", agentManagementRouter); - logger.info( - "Agent management routes enabled at :8080/api/v1/manage/agents/*" - ); - } - - // Agent selector is now handled by the unified settings page (/settings) - } - - // Auto-register any non-openapi routes so everything shows up in the schema - registerAutoOpenApiRoutes(app); - - // OpenAPI Documentation - app.doc("/api/docs/openapi.json", { - openapi: "3.0.0", - info: { - title: "Lobu API", - version: "1.0.0", - description: ` + if (httpServer) return; + + const app = new OpenAPIHono(); + + // Global middleware + app.use( + "*", + secureHeaders({ + xFrameOptions: "DENY", + xContentTypeOptions: "nosniff", + referrerPolicy: "strict-origin-when-cross-origin", + strictTransportSecurity: "max-age=63072000; includeSubDomains", + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: [ + "'self'", + "'unsafe-inline'", + "'unsafe-eval'", + "https://cdn.jsdelivr.net", + ], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'", "ws:", "wss:"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + }, + }), + ); + app.use( + "*", + cors({ + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(",") + : "*", + }), + ); + + // Health endpoints + app.get("/health", (c) => { + const mode = + process.env.LOBU_MODE || + (process.env.DEPLOYMENT_MODE === "docker" ? "local" : "cloud"); + + return c.json({ + status: "ok", + mode, + version: process.env.npm_package_version || "2.3.0", + timestamp: new Date().toISOString(), + publicGatewayUrl: + coreServices?.getPublicGatewayUrl?.() || process.env.PUBLIC_GATEWAY_URL, + capabilities: { + agents: ["claude"], + streaming: true, + toolApproval: true, + }, + wsUrl: `ws://localhost:8080/ws`, + secretProxy: !!secretProxy, + }); + }); + + app.get("/ready", (c) => c.json({ ready: true })); + + // Prometheus metrics endpoint + app.get("/metrics", async (c) => { + const metricsAuthToken = process.env.METRICS_AUTH_TOKEN; + if (metricsAuthToken) { + const authHeader = c.req.header("Authorization"); + if (authHeader !== `Bearer ${metricsAuthToken}`) { + return c.text("Unauthorized", 401); + } + } + const { getMetricsText } = await import("../metrics/prometheus"); + c.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + return c.text(getMetricsText()); + }); + + // Secret injection proxy (Hono) + if (secretProxy) { + app.route("/api/proxy", secretProxy.getApp()); + logger.info("Secret proxy enabled at :8080/api/proxy"); + } + + // Worker Gateway routes (Hono) + if (workerGateway) { + app.route("/worker", workerGateway.getApp()); + logger.info("Worker gateway routes enabled at :8080/worker/*"); + } + + // Register module endpoints + const { moduleRegistry: coreModuleRegistry } = require("@lobu/core"); + if (coreModuleRegistry.registerHonoEndpoints) { + coreModuleRegistry.registerHonoEndpoints(app); + } else { + // Create express-like adapter for module registry + const expressApp = createExpressAdapter(app); + coreModuleRegistry.registerEndpoints(expressApp); + } + logger.info("Module endpoints registered"); + + // MCP proxy routes (Hono) + if (mcpProxy) { + // Handle root path requests with X-Mcp-Id header + app.all("/", async (c, next) => { + if (mcpProxy.isMcpRequest(c)) { + // Forward to MCP proxy - need to handle directly since it's at root + return mcpProxy.getApp().fetch(c.req.raw); + } + return next(); + }); + // Mount MCP proxy at /mcp/* + app.route("/mcp", mcpProxy.getApp()); + logger.info("MCP proxy routes enabled at :8080/mcp/*"); + } + + // Telegram webhook route + const telegramWebhookRoute = telegramPlatform?.getWebhookRoute(); + if (telegramWebhookRoute) { + app.route(TELEGRAM_WEBHOOK_PATH, telegramWebhookRoute); + logger.info( + `Telegram webhook route enabled at :8080${TELEGRAM_WEBHOOK_PATH}`, + ); + } + + // Slack OAuth routes for multi-workspace distribution + if (platformRegistry) { + const slackAdapter = platformRegistry.get?.("slack"); + const slackInstallationStore = slackAdapter?.getInstallationStore?.(); + const slackClientId = process.env.SLACK_CLIENT_ID; + const slackClientSecret = process.env.SLACK_CLIENT_SECRET; + const publicGatewayUrl = process.env.PUBLIC_GATEWAY_URL; + + if ( + slackInstallationStore && + slackClientId && + slackClientSecret && + publicGatewayUrl + ) { + const { createSlackOAuthRoutes } = require("../slack/oauth-routes"); + const redis = coreServices?.getQueue?.()?.getRedisClient?.(); + if (redis) { + const slackOAuthRouter = createSlackOAuthRoutes({ + clientId: slackClientId, + clientSecret: slackClientSecret, + installationStore: slackInstallationStore, + redis, + publicGatewayUrl, + }); + app.route("/slack", slackOAuthRouter); + logger.info( + "Slack OAuth routes enabled at :8080/slack/install and :8080/slack/oauth_callback", + ); + } + } + } + + // File routes (already Hono) - uses platform registry for per-platform file handling + if (platformRegistry) { + const { createFileRoutes } = require("../routes/internal/files"); + const fileRouter = createFileRoutes(platformRegistry); + app.route("/internal/files", fileRouter); + logger.info("File routes enabled at :8080/internal/files/*"); + } + + // History routes (already Hono) + { + const { createHistoryRoutes } = require("../routes/internal/history"); + // Pass Slack installation store for multi-workspace token resolution + const slackAdapter = platformRegistry?.get?.("slack"); + const slackInstallationStore = slackAdapter?.getInstallationStore?.(); + const historyRouter = createHistoryRoutes(slackInstallationStore); + app.route("/internal", historyRouter); + logger.info("History routes enabled at :8080/internal/history"); + } + + // Schedule routes (worker scheduling endpoints) + if (coreServices) { + const scheduledWakeupService = coreServices.getScheduledWakeupService(); + if (scheduledWakeupService) { + const { createScheduleRoutes } = require("../routes/internal/schedule"); + const scheduleRouter = createScheduleRoutes(scheduledWakeupService); + app.route("", scheduleRouter); + logger.info("Schedule routes enabled at :8080/internal/schedule"); + } + } + + // Initialize auth session store for settings-auth module + if (coreServices) { + const { setSessionStore } = require("../routes/public/settings-auth"); + setSessionStore(coreServices.getAuthSessionStore()); + logger.info("Auth session store wired into settings-auth"); + } + + // Settings link routes (worker can generate settings links for users) + { + const { + createSettingsLinkRoutes, + } = require("../routes/internal/settings-link"); + const settingsLinkRouter = createSettingsLinkRoutes( + coreServices!.getAuthSessionStore(), + interactionService, + coreServices?.getGrantStore(), + ); + app.route("", settingsLinkRouter); + logger.info("Settings link routes enabled at :8080/internal/settings-link"); + } + + // MCP login routes (worker can trigger MCP OAuth login for users) + if (coreServices?.getMcpOAuthModule()) { + const { createMcpLoginRoutes } = require("../routes/internal/mcp-login"); + const mcpLoginRouter = createMcpLoginRoutes( + coreServices.getMcpOAuthModule(), + interactionService, + ); + app.route("", mcpLoginRouter); + logger.info("MCP login routes enabled at :8080/internal/mcp-login"); + } + + // Integrations discovery routes (unified skills + MCP search for workers) + { + const { + createIntegrationsDiscoveryRoutes, + } = require("../routes/internal/integrations-discovery"); + const { SkillRegistryCoordinator } = require("../services/skill-registry"); + const skillRegistryCoordinator = new SkillRegistryCoordinator(); + const integrationsDiscoveryRouter = createIntegrationsDiscoveryRoutes({ + coordinator: skillRegistryCoordinator, + agentSettingsStore: coreServices?.getAgentSettingsStore(), + integrationConfigService: coreServices?.getIntegrationConfigService(), + integrationCredentialStore: coreServices?.getIntegrationCredentialStore(), + }); + app.route("", integrationsDiscoveryRouter); + logger.info( + "Integrations discovery routes enabled at :8080/internal/integrations/*", + ); + } + + // Audio routes (TTS synthesis for workers) + if (coreServices) { + const transcriptionService = coreServices.getTranscriptionService(); + if (transcriptionService) { + const { createAudioRoutes } = require("../routes/internal/audio"); + const audioRouter = createAudioRoutes(transcriptionService); + app.route("", audioRouter); + logger.info("Audio routes enabled at :8080/internal/audio/*"); + } + } + + // Interaction routes (already Hono) + if (interactionService) { + const { + createInteractionRoutes, + } = require("../routes/internal/interactions"); + const internalRouter = createInteractionRoutes(interactionService); + app.route("", internalRouter); + logger.info("Internal interaction routes enabled"); + } + + // Messaging routes (already Hono) + if (platformRegistry) { + const { createMessagingRoutes } = require("../routes/public/messaging"); + const messagingRouter = createMessagingRoutes(platformRegistry); + app.route("", messagingRouter); + logger.info("Messaging routes enabled at :8080/api/v1/messaging/send"); + } + + // Agent API routes (direct API access) + if (coreServices) { + const queueProducer = coreServices.getQueueProducer(); + const sessionMgr = coreServices.getSessionManager(); + const interactionSvc = coreServices.getInteractionService(); + const publicUrl = coreServices.getPublicGatewayUrl(); + + if (queueProducer && sessionMgr && interactionSvc) { + // Agent API (Hono with OpenAPI docs) + const { createAgentApi } = require("../routes/public/agent"); + const agentApi = createAgentApi(queueProducer, sessionMgr, publicUrl); + app.route("", agentApi); + logger.info( + "Agent API enabled at :8080/api/v1/agents/* with docs at :8080/api/docs", + ); + } + } + + if (coreServices) { + // Mount OAuth modules under unified auth router + const authRouter = new OpenAPIHono(); + const registeredProviders: string[] = []; + + // Dynamically mount model provider auth routes + const providerModules = getModelProviderModules(); + + // Shared save-key + logout handlers (parameterized by :provider) + const authProfilesManager = coreServices.getAuthProfilesManager(); + if (authProfilesManager) { + const { + verifySettingsSession, + } = require("../routes/public/settings-auth"); + const { + createAuthProfileLabel, + } = require("../auth/settings/auth-profiles-manager"); + const agentMetadataStore = coreServices.getAgentMetadataStore(); + const userAgentsStore = coreServices.getUserAgentsStore(); + + /** Verify session cookie authorizes access to the given agentId */ + const verifyProviderAuth = async ( + c: any, + agentId: string, + ): Promise => { + const payload = await verifySettingsSession(c); + if (!payload) return false; + + // Agent-based token: must match exactly + if (payload.agentId) return payload.agentId === agentId; + + // Channel-based token: check user-agent association or metadata owner + if (userAgentsStore) { + const owns = await userAgentsStore.ownsAgent( + payload.platform, + payload.userId, + agentId, + ); + if (owns) return true; + } + if (agentMetadataStore) { + const metadata = await agentMetadataStore.getMetadata(agentId); + const isOwner = + metadata?.owner?.platform === payload.platform && + metadata?.owner?.userId === payload.userId; + if (isOwner) { + // Reconcile missing index + userAgentsStore + ?.addAgent(payload.platform, payload.userId, agentId) + .catch(() => { + /* best-effort reconciliation */ + }); + return true; + } + if (metadata?.isWorkspaceAgent) return true; + } + return false; + }; + + authRouter.post("/:provider/save-key", async (c: any) => { + try { + const providerId = c.req.param("provider"); + const mod = getModelProviderModules().find( + (m) => m.providerId === providerId, + ); + if (!mod) return c.json({ error: "Unknown provider" }, 404); + + const body = await c.req.json(); + c.__parsedBody = body; + const { agentId, apiKey } = body; + if (!agentId || !apiKey) { + return c.json({ error: "Missing agentId or apiKey" }, 400); + } + + if (!(await verifyProviderAuth(c, agentId))) { + return c.json({ error: "Unauthorized" }, 401); + } + + await authProfilesManager.upsertProfile({ + agentId, + provider: providerId, + credential: apiKey, + authType: "api-key", + label: createAuthProfileLabel(mod.providerDisplayName, apiKey), + makePrimary: true, + }); + + return c.json({ success: true }); + } catch (error) { + logger.error("Failed to save API key", { error }); + return c.json({ error: "Failed to save API key" }, 500); + } + }); + + authRouter.post("/:provider/logout", async (c: any) => { + try { + const providerId = c.req.param("provider"); + const mod = getModelProviderModules().find( + (m) => m.providerId === providerId, + ); + if (!mod) return c.json({ error: "Unknown provider" }, 404); + + const body = await c.req.json().catch(() => ({})); + c.__parsedBody = body; + const agentId = body.agentId || c.req.query("agentId"); + + if (!agentId) { + return c.json({ error: "Missing agentId" }, 400); + } + + if (!(await verifyProviderAuth(c, agentId))) { + return c.json({ error: "Unauthorized" }, 401); + } + + await authProfilesManager.deleteProviderProfiles( + agentId, + providerId, + body.profileId, + ); + + return c.json({ success: true }); + } catch (error) { + logger.error("Failed to logout", { error }); + return c.json({ error: "Failed to logout" }, 500); + } + }); + } + + for (const mod of providerModules) { + if (mod.getApp) { + authRouter.route(`/${mod.providerId}`, mod.getApp()); + registeredProviders.push(mod.providerId); + } + } + + const mcpOAuthModule = coreServices.getMcpOAuthModule(); + if (mcpOAuthModule) { + authRouter.route("/mcp", mcpOAuthModule.getApp()); + registeredProviders.push("mcp"); + } + + // Integration OAuth + internal routes + const integrationConfigService = coreServices.getIntegrationConfigService(); + const integrationCredentialStore = + coreServices.getIntegrationCredentialStore(); + const integrationOAuthModule = coreServices.getIntegrationOAuthModule(); + + if ( + integrationConfigService && + integrationCredentialStore && + integrationOAuthModule + ) { + // Internal routes for workers (list, connect, disconnect) + const { createIntegrationRoutes } = require("../auth/integration/routes"); + const publicGatewayUrl = coreServices.getPublicGatewayUrl(); + const integrationRouter = createIntegrationRoutes( + integrationConfigService, + integrationCredentialStore, + integrationOAuthModule, + publicGatewayUrl, + interactionService, + coreServices.getAgentSettingsStore(), + ); + app.route("", integrationRouter); + + // API proxy (credential injection + forwarding) + const { + createIntegrationApiProxy, + } = require("../auth/integration/api-proxy"); + const apiProxyRouter = createIntegrationApiProxy( + integrationConfigService, + integrationCredentialStore, + ); + app.route("", apiProxyRouter); + + // OAuth routes (public, for user browser redirects) + authRouter.route("/integration", integrationOAuthModule.getApp()); + registeredProviders.push("integration"); + + logger.info( + "Integration routes enabled at :8080/internal/integrations/*, :8080/api/v1/auth/integration/*", + ); + } + + // Get shared dependencies (needed before mounting auth router) + const agentSettingsStore = coreServices.getAgentSettingsStore(); + const claudeOAuthStateStore = coreServices.getClaudeOAuthStateStore(); + const scheduledWakeupService = coreServices.getScheduledWakeupService(); + + // Build provider stores and overrides dynamically from registered modules + const providerStores: Record< + string, + { hasCredentials(agentId: string): Promise } + > = {}; + const providerConnectedOverrides: Record = {}; + for (const mod of providerModules) { + providerStores[mod.providerId] = mod; + providerConnectedOverrides[mod.providerId] = mod.hasSystemKey(); + } + + // Settings HTML page + if (agentSettingsStore) { + const { createSettingsPageRoutes } = require("../routes/public/settings"); + const settingsPageRouter = createSettingsPageRoutes({ + agentSettingsStore, + userAgentsStore: coreServices.getUserAgentsStore(), + agentMetadataStore: coreServices.getAgentMetadataStore(), + channelBindingService: coreServices.getChannelBindingService(), + sessionStore: coreServices.getAuthSessionStore(), + oauthProvider: coreServices.getSettingsOAuthProvider(), + identityStore: coreServices.getOAuthIdentityStore(), + integrationConfigService: coreServices.getIntegrationConfigService(), + integrationCredentialStore: + coreServices.getIntegrationCredentialStore(), + connectionManager: coreServices + .getWorkerGateway() + ?.getConnectionManager(), + systemSkillsService: coreServices.getSystemSkillsService(), + }); + app.route("", settingsPageRouter); + logger.info("Settings HTML page enabled at :8080/settings"); + } + + // Landing page (docs + integrations) + { + const { createLandingRoutes } = require("../routes/public/landing"); + const landingRouter = createLandingRoutes({ + publicGatewayUrl: coreServices.getPublicGatewayUrl(), + githubUrl: "https://github.com/lobu-ai/lobu", + }); + app.route("", landingRouter); + logger.info("Landing page enabled at :8080/"); + } + + // Agent history routes (proxy to worker HTTP server) + { + const connectionManager = coreServices + .getWorkerGateway() + ?.getConnectionManager(); + if (connectionManager) { + const { + createAgentHistoryRoutes, + } = require("../routes/public/agent-history"); + const agentHistoryRouter = createAgentHistoryRoutes({ + connectionManager, + }); + app.route("/api/v1/agents/:agentId/history", agentHistoryRouter); + logger.info( + "Agent history routes enabled at :8080/api/v1/agents/{agentId}/history/*", + ); + + // History HTML page + const { renderHistoryPage } = require("../routes/public/history-page"); + const { + verifySettingsSession, + } = require("../routes/public/settings-auth"); + app.get("/agent/:agentId/history", (c: any) => { + const session = verifySettingsSession(c); + if (!session) { + return c.redirect("/settings"); + } + const agentId = c.req.param("agentId"); + return c.html(renderHistoryPage(agentId)); + }); + logger.info("History page enabled at :8080/agent/{agentId}/history"); + } + } + + // Agent config routes (/api/v1/agents/{id}/config) + if (agentSettingsStore) { + const { + createAgentConfigRoutes, + } = require("../routes/public/agent-config"); + + const agentConfigRouter = createAgentConfigRoutes({ + agentSettingsStore, + userAgentsStore: coreServices.getUserAgentsStore(), + agentMetadataStore: coreServices.getAgentMetadataStore(), + queue: coreServices.getQueue(), + providerStores: + Object.keys(providerStores).length > 0 ? providerStores : undefined, + providerConnectedOverrides, + providerCatalogService: coreServices.getProviderCatalogService(), + authProfilesManager: coreServices.getAuthProfilesManager(), + connectionManager: coreServices + .getWorkerGateway() + ?.getConnectionManager(), + grantStore: coreServices.getGrantStore(), + }); + app.route("/api/v1/agents/:agentId/config", agentConfigRouter); + logger.info( + "Agent config routes enabled at :8080/api/v1/agents/{id}/config", + ); + } + + // Agent schedules routes (/api/v1/agents/{id}/schedules) + { + const { + createAgentSchedulesRoutes, + } = require("../routes/public/agent-schedules"); + const agentSchedulesRouter = createAgentSchedulesRoutes({ + scheduledWakeupService, + userAgentsStore: coreServices.getUserAgentsStore(), + agentMetadataStore: coreServices.getAgentMetadataStore(), + }); + app.route("/api/v1/agents/:agentId/schedules", agentSchedulesRouter); + logger.info( + "Agent schedules routes enabled at :8080/api/v1/agents/{id}/schedules", + ); + } + + // Integrations routes (unified skills + MCP registry) + { + const { + createIntegrationsRoutes, + } = require("../routes/public/integrations"); + const integrationsRouter = createIntegrationsRoutes(); + app.route("/api/v1/integrations", integrationsRouter); + logger.info("Integrations routes enabled at :8080/api/v1/integrations/*"); + } + + // OAuth routes (mounted under unified auth router) + if (agentSettingsStore) { + const { createOAuthRoutes } = require("../routes/public/oauth"); + const { ClaudeOAuthClient } = require("../auth/oauth/claude-client"); + const claudeOAuthClient = new ClaudeOAuthClient(); + const oauthRouter = createOAuthRoutes({ + providerStores: + Object.keys(providerStores).length > 0 ? providerStores : undefined, + oauthClients: { claude: claudeOAuthClient }, + oauthStateStore: claudeOAuthStateStore, + }); + authRouter.route("", oauthRouter); + registeredProviders.push("oauth"); + } + + // Mount unified auth router (includes provider modules + OAuth) + if (registeredProviders.length > 0) { + app.route("/api/v1/auth", authRouter); + logger.info( + `Auth routes enabled at :8080/api/v1/auth/* for: ${registeredProviders.join(", ")}`, + ); + } + + // Channel binding routes (mount under agent API) + const channelBindingService = coreServices.getChannelBindingService(); + if (channelBindingService) { + const { + createChannelBindingRoutes, + } = require("../routes/public/channels"); + const channelBindingRouter = createChannelBindingRoutes({ + channelBindingService, + userAgentsStore: coreServices.getUserAgentsStore(), + agentMetadataStore: coreServices.getAgentMetadataStore(), + }); + // Mount as a sub-router under /api/v1/agents/:agentId/channels + app.route("/api/v1/agents/:agentId/channels", channelBindingRouter); + logger.info( + "Channel binding routes enabled at :8080/api/v1/agents/{agentId}/channels/*", + ); + } + + // Agent management routes (separate from Agent API's /api/v1/agents) + { + const userAgentsStore = coreServices.getUserAgentsStore(); + const agentMetadataStore = coreServices.getAgentMetadataStore(); + const { createAgentRoutes } = require("../routes/public/agents"); + const agentManagementRouter = createAgentRoutes({ + userAgentsStore, + agentMetadataStore, + agentSettingsStore, + channelBindingService, + }); + app.route("/api/v1/manage/agents", agentManagementRouter); + logger.info( + "Agent management routes enabled at :8080/api/v1/manage/agents/*", + ); + } + + // Agent selector is now handled by the unified settings page (/settings) + } + + // Auto-register any non-openapi routes so everything shows up in the schema + registerAutoOpenApiRoutes(app); + + // OpenAPI Documentation + app.doc("/api/docs/openapi.json", { + openapi: "3.0.0", + info: { + title: "Lobu API", + version: "1.0.0", + description: ` ## Overview The Lobu API allows you to create and interact with AI agents programmatically. @@ -752,452 +744,452 @@ Agents can be configured with custom MCP (Model Context Protocol) servers: } \`\`\` `, - }, - tags: [ - { - name: "Agents", - description: - "Create, manage, and configure AI agents. Includes config (model, network, env vars) and schedules (wakeups, reminders).", - }, - { - name: "Agent Messages", - description: - "Send messages to agents and handle pending tool interactions.", - }, - { - name: "Channels", - description: - "Bind agents to platform channels (Slack, Telegram). Messages from bound channels are routed to the agent.", - }, - { - name: "Messaging", - description: - "Send messages through platform adapters (Slack, Telegram, API).", - }, - { - name: "Auth", - description: - "Authentication flows — API key, OAuth code exchange, device code for Claude and other providers.", - }, - { - name: "Webhooks", - description: "Platform webhook endpoints (Telegram, Slack OAuth).", - }, - { - name: "Integrations", - description: - "Browse and manage skills, MCP servers, and other integrations.", - }, - { - name: "Settings", - description: "Settings page session management.", - }, - { - name: "System", - description: "Health checks, metrics, and system status.", - }, - ], - servers: [ - { url: "http://localhost:8080", description: "Local development" }, - ], - }); - - app.get( - "/api/docs", - apiReference({ - url: "/api/docs/openapi.json", - theme: "kepler", - layout: "modern", - defaultHttpClient: { targetKey: "js", clientKey: "fetch" }, - }) - ); - logger.info("API docs enabled at :8080/api/docs"); - - // Start the server — single port for everything - const port = 8080; - const honoListener = getRequestListener(app.fetch); - - httpServer = createServer((incoming, outgoing) => { - // Route Slack event webhooks to the Bolt Express receiver - if (slackExpressApp && incoming.url?.startsWith("/slack/events")) { - slackExpressApp(incoming, outgoing); - return; - } - // Everything else goes through Hono - honoListener(incoming, outgoing); - }); - - httpServer.listen(port); - logger.info(`Server listening on port ${port}`); + }, + tags: [ + { + name: "Agents", + description: + "Create, manage, and configure AI agents. Includes config (model, network, env vars) and schedules (wakeups, reminders).", + }, + { + name: "Agent Messages", + description: + "Send messages to agents and handle pending tool interactions.", + }, + { + name: "Channels", + description: + "Bind agents to platform channels (Slack, Telegram). Messages from bound channels are routed to the agent.", + }, + { + name: "Messaging", + description: + "Send messages through platform adapters (Slack, Telegram, API).", + }, + { + name: "Auth", + description: + "Authentication flows — API key, OAuth code exchange, device code for Claude and other providers.", + }, + { + name: "Webhooks", + description: "Platform webhook endpoints (Telegram, Slack OAuth).", + }, + { + name: "Integrations", + description: + "Browse and manage skills, MCP servers, and other integrations.", + }, + { + name: "Settings", + description: "Settings page session management.", + }, + { + name: "System", + description: "Health checks, metrics, and system status.", + }, + ], + servers: [ + { url: "http://localhost:8080", description: "Local development" }, + ], + }); + + app.get( + "/api/docs", + apiReference({ + url: "/api/docs/openapi.json", + theme: "kepler", + layout: "modern", + defaultHttpClient: { targetKey: "js", clientKey: "fetch" }, + }), + ); + logger.info("API docs enabled at :8080/api/docs"); + + // Start the server — single port for everything + const port = 8080; + const honoListener = getRequestListener(app.fetch); + + httpServer = createServer((incoming, outgoing) => { + // Route Slack event webhooks to the Bolt Express receiver + if (slackExpressApp && incoming.url?.startsWith("/slack/events")) { + slackExpressApp(incoming, outgoing); + return; + } + // Everything else goes through Hono + honoListener(incoming, outgoing); + }); + + httpServer.listen(port); + logger.info(`Server listening on port ${port}`); } /** * Handle Express-style handler with Hono context */ async function handleExpressHandler(c: any, handler: any): Promise { - const { req, res, responsePromise } = createExpressCompatObjects(c); - await handler(req, res); - return responsePromise; + const { req, res, responsePromise } = createExpressCompatObjects(c); + await handler(req, res); + return responsePromise; } /** * Create Express-compatible request/response objects from Hono context */ function createExpressCompatObjects(c: any, overridePath?: string) { - let resolveResponse: (response: Response) => void; - const responsePromise = new Promise((resolve) => { - resolveResponse = resolve; - }); - - const url = new URL(c.req.url); - const headers: Record = {}; - c.req.raw.headers.forEach((value: string, key: string) => { - headers[key] = value; - }); - - // Express-compatible request object - const req: any = { - method: c.req.method, - url: c.req.url, - path: overridePath || url.pathname, - headers, - query: Object.fromEntries(url.searchParams), - params: c.req.param() || {}, - body: null, - get: (name: string) => headers[name.toLowerCase()], - on: () => { - // Express event listener stub - not used in Hono compat layer - }, - }; - - // Response state - let statusCode = 200; - const responseHeaders = new Headers(); - let isStreaming = false; - let streamController: ReadableStreamDefaultController | null = - null; - - // Express-compatible response object - const res: any = { - statusCode: 200, - destroyed: false, - writableEnded: false, - - status(code: number) { - statusCode = code; - this.statusCode = code; - return this; - }, - - setHeader(name: string, value: string) { - responseHeaders.set(name, value); - return this; - }, - - set(name: string, value: string) { - responseHeaders.set(name, value); - return this; - }, - - json(data: any) { - responseHeaders.set("Content-Type", "application/json"); - resolveResponse?.( - new Response(JSON.stringify(data), { - status: statusCode, - headers: responseHeaders, - }) - ); - }, - - send(data: any) { - resolveResponse?.( - new Response(data, { - status: statusCode, - headers: responseHeaders, - }) - ); - }, - - text(data: string) { - resolveResponse?.( - new Response(data, { - status: statusCode, - headers: responseHeaders, - }) - ); - }, - - end(data?: any) { - this.writableEnded = true; - if (isStreaming && streamController) { - if (data) { - streamController.enqueue( - typeof data === "string" ? new TextEncoder().encode(data) : data - ); - } - streamController.close(); - } else { - resolveResponse?.( - new Response(data || null, { - status: statusCode, - headers: responseHeaders, - }) - ); - } - }, - - write(chunk: any) { - if (!isStreaming) { - isStreaming = true; - const stream = new ReadableStream({ - start(controller) { - streamController = controller; - if (chunk) { - controller.enqueue( - typeof chunk === "string" - ? new TextEncoder().encode(chunk) - : chunk - ); - } - }, - }); - resolveResponse?.( - new Response(stream, { - status: statusCode, - headers: responseHeaders, - }) - ); - } else if (streamController) { - streamController.enqueue( - typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk - ); - } - return true; - }, - - flushHeaders() { - // No-op for compatibility - }, - }; - - // Parse body for POST/PUT/PATCH - if (["POST", "PUT", "PATCH"].includes(c.req.method)) { - const contentType = c.req.header("content-type") || ""; - c.req.raw - .clone() - .arrayBuffer() - .then((buffer: ArrayBuffer) => { - if (contentType.includes("application/json")) { - try { - req.body = JSON.parse(new TextDecoder().decode(buffer)); - } catch { - req.body = buffer; - } - } else { - req.body = buffer; - } - }); - } - - return { req, res, responsePromise }; + let resolveResponse: (response: Response) => void; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + + const url = new URL(c.req.url); + const headers: Record = {}; + c.req.raw.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + // Express-compatible request object + const req: any = { + method: c.req.method, + url: c.req.url, + path: overridePath || url.pathname, + headers, + query: Object.fromEntries(url.searchParams), + params: c.req.param() || {}, + body: null, + get: (name: string) => headers[name.toLowerCase()], + on: () => { + // Express event listener stub - not used in Hono compat layer + }, + }; + + // Response state + let statusCode = 200; + const responseHeaders = new Headers(); + let isStreaming = false; + let streamController: ReadableStreamDefaultController | null = + null; + + // Express-compatible response object + const res: any = { + statusCode: 200, + destroyed: false, + writableEnded: false, + + status(code: number) { + statusCode = code; + this.statusCode = code; + return this; + }, + + setHeader(name: string, value: string) { + responseHeaders.set(name, value); + return this; + }, + + set(name: string, value: string) { + responseHeaders.set(name, value); + return this; + }, + + json(data: any) { + responseHeaders.set("Content-Type", "application/json"); + resolveResponse?.( + new Response(JSON.stringify(data), { + status: statusCode, + headers: responseHeaders, + }), + ); + }, + + send(data: any) { + resolveResponse?.( + new Response(data, { + status: statusCode, + headers: responseHeaders, + }), + ); + }, + + text(data: string) { + resolveResponse?.( + new Response(data, { + status: statusCode, + headers: responseHeaders, + }), + ); + }, + + end(data?: any) { + this.writableEnded = true; + if (isStreaming && streamController) { + if (data) { + streamController.enqueue( + typeof data === "string" ? new TextEncoder().encode(data) : data, + ); + } + streamController.close(); + } else { + resolveResponse?.( + new Response(data || null, { + status: statusCode, + headers: responseHeaders, + }), + ); + } + }, + + write(chunk: any) { + if (!isStreaming) { + isStreaming = true; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + if (chunk) { + controller.enqueue( + typeof chunk === "string" + ? new TextEncoder().encode(chunk) + : chunk, + ); + } + }, + }); + resolveResponse?.( + new Response(stream, { + status: statusCode, + headers: responseHeaders, + }), + ); + } else if (streamController) { + streamController.enqueue( + typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk, + ); + } + return true; + }, + + flushHeaders() { + // No-op for compatibility + }, + }; + + // Parse body for POST/PUT/PATCH + if (["POST", "PUT", "PATCH"].includes(c.req.method)) { + const contentType = c.req.header("content-type") || ""; + c.req.raw + .clone() + .arrayBuffer() + .then((buffer: ArrayBuffer) => { + if (contentType.includes("application/json")) { + try { + req.body = JSON.parse(new TextDecoder().decode(buffer)); + } catch { + req.body = buffer; + } + } else { + req.body = buffer; + } + }); + } + + return { req, res, responsePromise }; } /** * Create Express-like adapter for compatibility with module registry */ function createExpressAdapter(honoApp: any) { - return { - get: (path: string, ...handlers: any[]) => { - const handler = handlers[handlers.length - 1]; - honoApp.get(path, (c: any) => handleExpressHandler(c, handler)); - }, - post: (path: string, ...handlers: any[]) => { - const handler = handlers[handlers.length - 1]; - honoApp.post(path, (c: any) => handleExpressHandler(c, handler)); - }, - put: (path: string, ...handlers: any[]) => { - const handler = handlers[handlers.length - 1]; - honoApp.put(path, (c: any) => handleExpressHandler(c, handler)); - }, - delete: (path: string, ...handlers: any[]) => { - const handler = handlers[handlers.length - 1]; - honoApp.delete(path, (c: any) => handleExpressHandler(c, handler)); - }, - use: (pathOrHandler: any, handler?: any) => { - if (typeof pathOrHandler === "function") { - // Global middleware - skip for now - } else if (handler) { - honoApp.all(`${pathOrHandler}/*`, (c: any) => - handleExpressHandler(c, handler) - ); - } - }, - }; + return { + get: (path: string, ...handlers: any[]) => { + const handler = handlers[handlers.length - 1]; + honoApp.get(path, (c: any) => handleExpressHandler(c, handler)); + }, + post: (path: string, ...handlers: any[]) => { + const handler = handlers[handlers.length - 1]; + honoApp.post(path, (c: any) => handleExpressHandler(c, handler)); + }, + put: (path: string, ...handlers: any[]) => { + const handler = handlers[handlers.length - 1]; + honoApp.put(path, (c: any) => handleExpressHandler(c, handler)); + }, + delete: (path: string, ...handlers: any[]) => { + const handler = handlers[handlers.length - 1]; + honoApp.delete(path, (c: any) => handleExpressHandler(c, handler)); + }, + use: (pathOrHandler: any, handler?: any) => { + if (typeof pathOrHandler === "function") { + // Global middleware - skip for now + } else if (handler) { + honoApp.all(`${pathOrHandler}/*`, (c: any) => + handleExpressHandler(c, handler), + ); + } + }, + }; } /** * Start the gateway with the provided configuration */ export async function startGateway( - config: GatewayConfig, - slackConfig: SlackConfig | null, - whatsappConfig?: WhatsAppConfig | null, - telegramConfig?: TelegramConfig | null + config: GatewayConfig, + slackConfig: SlackConfig | null, + whatsappConfig?: WhatsAppConfig | null, + telegramConfig?: TelegramConfig | null, ): Promise { - logger.info("Starting Lobu Gateway"); - - // Start filtering proxy for worker network isolation (if enabled) - const { startFilteringProxy } = await import("../proxy/proxy-manager"); - await startFilteringProxy(); - - // Import dependencies - const { Orchestrator } = await import("../orchestration"); - const { Gateway } = await import("../gateway-main"); - - // Create and start orchestrator - logger.debug("Creating orchestrator", { mode: process.env.DEPLOYMENT_MODE }); - const orchestrator = new Orchestrator(config.orchestration); - await orchestrator.start(); - logger.info("Orchestrator started"); - - // Create Gateway - const gateway = new Gateway(config); - - const agentOptions = { - allowedTools: config.agentDefaults.allowedTools, - disallowedTools: config.agentDefaults.disallowedTools, - runtime: config.agentDefaults.runtime, - model: config.agentDefaults.model, - timeoutMinutes: config.agentDefaults.timeoutMinutes, - pluginsConfig: config.agentDefaults.pluginsConfig, - }; - - // Register Slack platform if configured - let slackPlatform: any = null; - if (slackConfig) { - const { SlackPlatform } = await import("../slack"); - - const slackPlatformConfig = { - slack: slackConfig, - logLevel: config.logLevel as any, - health: config.health, - }; - - slackPlatform = new SlackPlatform( - slackPlatformConfig, - agentOptions, - config.sessionTimeoutMinutes - ); - gateway.registerPlatform(slackPlatform); - logger.info("Slack platform registered"); - } - - // Register WhatsApp platform if enabled - let whatsappPlatform: any = null; - logger.debug("WhatsApp config", { enabled: whatsappConfig?.enabled }); - if (whatsappConfig?.enabled) { - const { WhatsAppPlatform } = await import("../whatsapp"); - - const whatsappPlatformConfig = { - whatsapp: whatsappConfig, - }; - - whatsappPlatform = new WhatsAppPlatform( - whatsappPlatformConfig, - agentOptions, - config.sessionTimeoutMinutes - ); - gateway.registerPlatform(whatsappPlatform); - logger.info("WhatsApp platform registered"); - } - - // Register Telegram platform if enabled - let telegramPlatform: any = null; - logger.debug("Telegram config", { enabled: telegramConfig?.enabled }); - if (telegramConfig?.enabled) { - const { TelegramPlatform } = await import("../telegram"); - - const telegramPlatformConfig = { - telegram: telegramConfig, - }; - - telegramPlatform = new TelegramPlatform( - telegramPlatformConfig, - agentOptions, - config.sessionTimeoutMinutes - ); - gateway.registerPlatform(telegramPlatform); - logger.info("Telegram platform registered"); - } - - // Register API platform (always enabled) - const { ApiPlatform } = await import("../api"); - const apiPlatform = new ApiPlatform(); - gateway.registerPlatform(apiPlatform); - logger.info("API platform registered"); - - // Start gateway - await gateway.start(); - logger.info("Gateway started"); - - // Get core services - const coreServices = gateway.getCoreServices(); - - // Wire grant store to HTTP proxy for domain grant checks - const grantStore = coreServices.getGrantStore(); - if (grantStore) { - const { setProxyGrantStore } = await import("../proxy/http-proxy"); - setProxyGrantStore(grantStore); - logger.info("Grant store connected to HTTP proxy"); - } - - // Inject core services into orchestrator (provider modules carry their own credential stores) - await orchestrator.injectCoreServices( - coreServices.getQueue().getRedisClient(), - coreServices.getProviderCatalogService(), - coreServices.getGrantStore() ?? undefined - ); - logger.info("Orchestrator configured with core services"); - - // Setup server on port 8080 (single port for all HTTP traffic) - setupServer( - coreServices.getSecretProxy(), - coreServices.getWorkerGateway(), - coreServices.getMcpProxy(), - coreServices.getInteractionService(), - gateway.getPlatformRegistry(), - coreServices, - telegramPlatform, - slackPlatform?.getExpressApp() - ); - - logger.info("Lobu Gateway is running!"); - - // Setup graceful shutdown - const cleanup = async () => { - logger.info("Shutting down gateway..."); - - // Hard deadline: force exit after 30s if graceful shutdown stalls - const hardDeadline = setTimeout(() => { - logger.error("Graceful shutdown timed out after 30s, forcing exit"); - process.exit(1); - }, 30_000); - hardDeadline.unref(); - - await orchestrator.stop(); - await gateway.stop(); - if (httpServer) { - httpServer.close(); - } - logger.info("Gateway shutdown complete"); - process.exit(0); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - - process.on("SIGUSR1", () => { - const status = gateway.getStatus(); - logger.info("Health check:", JSON.stringify(status, null, 2)); - }); + logger.info("Starting Lobu Gateway"); + + // Start filtering proxy for worker network isolation (if enabled) + const { startFilteringProxy } = await import("../proxy/proxy-manager"); + await startFilteringProxy(); + + // Import dependencies + const { Orchestrator } = await import("../orchestration"); + const { Gateway } = await import("../gateway-main"); + + // Create and start orchestrator + logger.debug("Creating orchestrator", { mode: process.env.DEPLOYMENT_MODE }); + const orchestrator = new Orchestrator(config.orchestration); + await orchestrator.start(); + logger.info("Orchestrator started"); + + // Create Gateway + const gateway = new Gateway(config); + + const agentOptions = { + allowedTools: config.agentDefaults.allowedTools, + disallowedTools: config.agentDefaults.disallowedTools, + runtime: config.agentDefaults.runtime, + model: config.agentDefaults.model, + timeoutMinutes: config.agentDefaults.timeoutMinutes, + pluginsConfig: config.agentDefaults.pluginsConfig, + }; + + // Register Slack platform if configured + let slackPlatform: any = null; + if (slackConfig) { + const { SlackPlatform } = await import("../slack"); + + const slackPlatformConfig = { + slack: slackConfig, + logLevel: config.logLevel as any, + health: config.health, + }; + + slackPlatform = new SlackPlatform( + slackPlatformConfig, + agentOptions, + config.sessionTimeoutMinutes, + ); + gateway.registerPlatform(slackPlatform); + logger.info("Slack platform registered"); + } + + // Register WhatsApp platform if enabled + let whatsappPlatform: any = null; + logger.debug("WhatsApp config", { enabled: whatsappConfig?.enabled }); + if (whatsappConfig?.enabled) { + const { WhatsAppPlatform } = await import("../whatsapp"); + + const whatsappPlatformConfig = { + whatsapp: whatsappConfig, + }; + + whatsappPlatform = new WhatsAppPlatform( + whatsappPlatformConfig, + agentOptions, + config.sessionTimeoutMinutes, + ); + gateway.registerPlatform(whatsappPlatform); + logger.info("WhatsApp platform registered"); + } + + // Register Telegram platform if enabled + let telegramPlatform: any = null; + logger.debug("Telegram config", { enabled: telegramConfig?.enabled }); + if (telegramConfig?.enabled) { + const { TelegramPlatform } = await import("../telegram"); + + const telegramPlatformConfig = { + telegram: telegramConfig, + }; + + telegramPlatform = new TelegramPlatform( + telegramPlatformConfig, + agentOptions, + config.sessionTimeoutMinutes, + ); + gateway.registerPlatform(telegramPlatform); + logger.info("Telegram platform registered"); + } + + // Register API platform (always enabled) + const { ApiPlatform } = await import("../api"); + const apiPlatform = new ApiPlatform(); + gateway.registerPlatform(apiPlatform); + logger.info("API platform registered"); + + // Start gateway + await gateway.start(); + logger.info("Gateway started"); + + // Get core services + const coreServices = gateway.getCoreServices(); + + // Wire grant store to HTTP proxy for domain grant checks + const grantStore = coreServices.getGrantStore(); + if (grantStore) { + const { setProxyGrantStore } = await import("../proxy/http-proxy"); + setProxyGrantStore(grantStore); + logger.info("Grant store connected to HTTP proxy"); + } + + // Inject core services into orchestrator (provider modules carry their own credential stores) + await orchestrator.injectCoreServices( + coreServices.getQueue().getRedisClient(), + coreServices.getProviderCatalogService(), + coreServices.getGrantStore() ?? undefined, + ); + logger.info("Orchestrator configured with core services"); + + // Setup server on port 8080 (single port for all HTTP traffic) + setupServer( + coreServices.getSecretProxy(), + coreServices.getWorkerGateway(), + coreServices.getMcpProxy(), + coreServices.getInteractionService(), + gateway.getPlatformRegistry(), + coreServices, + telegramPlatform, + slackPlatform?.getExpressApp(), + ); + + logger.info("Lobu Gateway is running!"); + + // Setup graceful shutdown + const cleanup = async () => { + logger.info("Shutting down gateway..."); + + // Hard deadline: force exit after 30s if graceful shutdown stalls + const hardDeadline = setTimeout(() => { + logger.error("Graceful shutdown timed out after 30s, forcing exit"); + process.exit(1); + }, 30_000); + hardDeadline.unref(); + + await orchestrator.stop(); + await gateway.stop(); + if (httpServer) { + httpServer.close(); + } + logger.info("Gateway shutdown complete"); + process.exit(0); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + process.on("SIGUSR1", () => { + const status = gateway.getStatus(); + logger.info("Health check:", JSON.stringify(status, null, 2)); + }); } diff --git a/packages/gateway/src/commands/built-in-commands.ts b/packages/gateway/src/commands/built-in-commands.ts index e599aceb3..46c68478a 100644 --- a/packages/gateway/src/commands/built-in-commands.ts +++ b/packages/gateway/src/commands/built-in-commands.ts @@ -1,105 +1,110 @@ import { - type CommandContext, - type CommandRegistry, - createLogger, + type CommandContext, + type CommandRegistry, + createLogger, } from "@lobu/core"; import type { AgentSettingsStore } from "../auth/settings"; import { - buildSettingsUrl, - buildTelegramSettingsUrl, - formatSettingsTokenTtl, - generateSettingsToken, + buildTelegramSettingsUrl, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, } from "../auth/settings"; +import type { AuthSessionStore } from "../auth/settings/session-store"; +import { buildSessionUrl } from "../auth/settings/session-store"; const logger = createLogger("built-in-commands"); export interface BuiltInCommandDeps { - agentSettingsStore: AgentSettingsStore; + agentSettingsStore: AgentSettingsStore; + sessionStore: AuthSessionStore; } /** * Register all built-in slash commands on the given registry. */ export function registerBuiltInCommands( - registry: CommandRegistry, - deps: BuiltInCommandDeps + registry: CommandRegistry, + deps: BuiltInCommandDeps, ): void { - registry.register({ - name: "configure", - description: "Open agent settings page", - handler: async (ctx: CommandContext) => { - logger.info( - { userId: ctx.userId, agentId: ctx.agentId }, - "/configure command" - ); - if (!ctx.agentId) { - await ctx.reply("No agent is configured for this conversation yet."); - return; - } + registry.register({ + name: "configure", + description: "Open agent settings page", + handler: async (ctx: CommandContext) => { + logger.info( + { userId: ctx.userId, agentId: ctx.agentId }, + "/configure command", + ); + if (!ctx.agentId) { + await ctx.reply("No agent is configured for this conversation yet."); + return; + } - if (ctx.platform === "telegram") { - const settingsUrl = buildTelegramSettingsUrl(ctx.channelId); - await ctx.reply( - "Here's your settings link.\n\nUse this page to configure your agent's model, network access, and more.", - { url: settingsUrl, urlLabel: "Open Settings" } - ); - return; - } + if (ctx.platform === "telegram") { + const settingsUrl = buildTelegramSettingsUrl(ctx.channelId); + await ctx.reply( + "Here's your settings link.\n\nUse this page to configure your agent's model, network access, and more.", + { url: settingsUrl, urlLabel: "Open Settings" }, + ); + return; + } - const token = generateSettingsToken( - ctx.agentId, - ctx.userId, - ctx.platform, - { channelId: ctx.channelId } - ); - const settingsUrl = buildSettingsUrl(token); - const ttlLabel = formatSettingsTokenTtl(); - await ctx.reply( - `Here's your settings link (valid for ${ttlLabel}).\n\nUse this page to configure your agent's model, network access, and more.`, - { url: settingsUrl, urlLabel: "Open Settings" } - ); - }, - }); + const { sessionId } = await deps.sessionStore.createSession( + { + userId: ctx.userId, + platform: ctx.platform, + agentId: ctx.agentId, + channelId: ctx.channelId, + }, + getSettingsTokenTtlMs(), + ); + const settingsUrl = buildSessionUrl(sessionId); + const ttlLabel = formatSettingsTokenTtl(); + await ctx.reply( + `Here's your settings link (valid for ${ttlLabel}).\n\nUse this page to configure your agent's model, network access, and more.`, + { url: settingsUrl, urlLabel: "Open Settings" }, + ); + }, + }); - registry.register({ - name: "help", - description: "Show available commands", - handler: async (ctx: CommandContext) => { - const commands = registry.getAll(); - const lines = commands.map((c) => `/${c.name} - ${c.description}`); - await ctx.reply( - `Available commands:\n${lines.join("\n")}\n\nYou can also just send a message to start a conversation with the agent.` - ); - }, - }); + registry.register({ + name: "help", + description: "Show available commands", + handler: async (ctx: CommandContext) => { + const commands = registry.getAll(); + const lines = commands.map((c) => `/${c.name} - ${c.description}`); + await ctx.reply( + `Available commands:\n${lines.join("\n")}\n\nYou can also just send a message to start a conversation with the agent.`, + ); + }, + }); - registry.register({ - name: "status", - description: "Show current agent status", - handler: async (ctx: CommandContext) => { - if (!ctx.agentId) { - await ctx.reply("No agent is configured for this conversation yet."); - return; - } + registry.register({ + name: "status", + description: "Show current agent status", + handler: async (ctx: CommandContext) => { + if (!ctx.agentId) { + await ctx.reply("No agent is configured for this conversation yet."); + return; + } - const settings = await deps.agentSettingsStore.getSettings(ctx.agentId); + const settings = await deps.agentSettingsStore.getSettings(ctx.agentId); - const model = settings?.model || "default"; - const mcpCount = settings?.mcpServers - ? Object.keys(settings.mcpServers).length - : 0; - const skillsCount = settings?.skillsConfig?.skills - ? Object.keys(settings.skillsConfig.skills).length - : 0; + const model = settings?.model || "default"; + const mcpCount = settings?.mcpServers + ? Object.keys(settings.mcpServers).length + : 0; + const skillsCount = settings?.skillsConfig?.skills + ? Object.keys(settings.skillsConfig.skills).length + : 0; - const parts = [ - `Agent: ${ctx.agentId}`, - `Model: ${model}`, - `MCP servers: ${mcpCount}`, - `Skills: ${skillsCount}`, - ]; + const parts = [ + `Agent: ${ctx.agentId}`, + `Model: ${model}`, + `MCP servers: ${mcpCount}`, + `Skills: ${skillsCount}`, + ]; - await ctx.reply(parts.join("\n")); - }, - }); + await ctx.reply(parts.join("\n")); + }, + }); } diff --git a/packages/gateway/src/platform/link-buttons.ts b/packages/gateway/src/platform/link-buttons.ts index e18d682bc..be208c78e 100644 --- a/packages/gateway/src/platform/link-buttons.ts +++ b/packages/gateway/src/platform/link-buttons.ts @@ -1,34 +1,34 @@ /** * Extract settings link buttons from markdown content. * - * Scans for markdown links pointing to `/settings#st=...` URLs and + * Scans for markdown links pointing to `/settings?s=...` URLs and * returns them as structured button data, stripping the link syntax * from the content so platforms can render native buttons instead. */ const SETTINGS_LINK_RE = - /\[([^\]]+)\]\((https?:\/\/[^)]*\/settings[#?]st=[^)]+)\)/g; + /\[([^\]]+)\]\((https?:\/\/[^)]*\/settings\?s=[^)]+)\)/g; /** * Returns true when the URL points to a loopback address that * Telegram (and other platforms) reject for inline keyboard buttons. */ function isLocalhostUrl(url: string): boolean { - try { - const u = new URL(url); - return ( - u.hostname === "localhost" || - u.hostname === "127.0.0.1" || - u.hostname === "::1" - ); - } catch { - return true; - } + try { + const u = new URL(url); + return ( + u.hostname === "localhost" || + u.hostname === "127.0.0.1" || + u.hostname === "::1" + ); + } catch { + return true; + } } export interface LinkButton { - text: string; - url: string; + text: string; + url: string; } /** @@ -37,21 +37,21 @@ export interface LinkButton { * text so the surrounding prose still reads naturally. */ export function extractSettingsLinkButtons(content: string): { - processedContent: string; - linkButtons: LinkButton[]; + processedContent: string; + linkButtons: LinkButton[]; } { - const linkButtons: LinkButton[] = []; + const linkButtons: LinkButton[] = []; - const processedContent = content.replace( - SETTINGS_LINK_RE, - (_match, text: string, url: string) => { - if (!isLocalhostUrl(url)) { - linkButtons.push({ text, url }); - } - // Replace the markdown link with just the label text - return text; - } - ); + const processedContent = content.replace( + SETTINGS_LINK_RE, + (_match, text: string, url: string) => { + if (!isLocalhostUrl(url)) { + linkButtons.push({ text, url }); + } + // Replace the markdown link with just the label text + return text; + }, + ); - return { processedContent, linkButtons }; + return { processedContent, linkButtons }; } diff --git a/packages/gateway/src/routes/internal/settings-link.ts b/packages/gateway/src/routes/internal/settings-link.ts index d6a977032..3bcc52ffe 100644 --- a/packages/gateway/src/routes/internal/settings-link.ts +++ b/packages/gateway/src/routes/internal/settings-link.ts @@ -10,10 +10,10 @@ import { Hono } from "hono"; import type { AuthSessionStore } from "../../auth/settings/session-store"; import { buildSessionUrl } from "../../auth/settings/session-store"; import { - buildTelegramSettingsUrl, - getSettingsTokenTtlMs, - type PrefillMcpServer, - type PrefillSkill, + buildTelegramSettingsUrl, + getSettingsTokenTtlMs, + type PrefillMcpServer, + type PrefillSkill, } from "../../auth/settings/token-service"; import type { InteractionService } from "../../interactions"; import type { GrantStore } from "../../permissions/grant-store"; @@ -21,250 +21,247 @@ import type { GrantStore } from "../../permissions/grant-store"; const logger = createLogger("internal-settings-link-routes"); type WorkerContext = { - Variables: { - worker: { - userId: string; - conversationId: string; - channelId: string; - teamId?: string; - agentId?: string; - deploymentName: string; - platform?: string; - }; - }; + Variables: { + worker: { + userId: string; + conversationId: string; + channelId: string; + teamId?: string; + agentId?: string; + deploymentName: string; + platform?: string; + }; + }; }; /** * Create internal settings link routes (Hono) */ export function createSettingsLinkRoutes( - sessionStore: AuthSessionStore, - interactionService?: InteractionService, - grantStore?: GrantStore + sessionStore: AuthSessionStore, + interactionService?: InteractionService, + grantStore?: GrantStore, ): Hono { - const router = new Hono(); + const router = new Hono(); - // Worker authentication middleware - const authenticateWorker = async (c: any, next: () => Promise) => { - const authHeader = c.req.header("authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json({ error: "Missing or invalid authorization" }, 401); - } - const workerToken = authHeader.substring(7); - const tokenData = verifyWorkerToken(workerToken); - if (!tokenData) { - return c.json({ error: "Invalid worker token" }, 401); - } - c.set("worker", tokenData); - await next(); - }; + // Worker authentication middleware + const authenticateWorker = async (c: any, next: () => Promise) => { + const authHeader = c.req.header("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Missing or invalid authorization" }, 401); + } + const workerToken = authHeader.substring(7); + const tokenData = verifyWorkerToken(workerToken); + if (!tokenData) { + return c.json({ error: "Invalid worker token" }, 401); + } + c.set("worker", tokenData); + await next(); + }; - /** - * Generate a settings link for the current user/agent context. - * Context is stored server-side in Redis — only an opaque session ID - * appears in the URL. - * - * POST /internal/settings-link - */ - router.post("/internal/settings-link", authenticateWorker, async (c) => { - try { - const worker = c.get("worker"); - const body = await c.req.json().catch(() => ({})); - const { - reason, - message, - label, - prefillEnvVars, - prefillSkills, - prefillMcpServers, - prefillNixPackages, - prefillGrants, - } = body as { - reason?: string; - message?: string; - label?: string; - prefillEnvVars?: string[]; - prefillSkills?: PrefillSkill[]; - prefillMcpServers?: PrefillMcpServer[]; - prefillNixPackages?: string[]; - prefillGrants?: string[]; - }; + /** + * Generate a settings link for the current user/agent context. + * Context is stored server-side in Redis — only an opaque session ID + * appears in the URL. + * + * POST /internal/settings-link + */ + router.post("/internal/settings-link", authenticateWorker, async (c) => { + try { + const worker = c.get("worker"); + const body = await c.req.json().catch(() => ({})); + const { + reason, + message, + label, + prefillEnvVars, + prefillSkills, + prefillMcpServers, + prefillNixPackages, + prefillGrants, + } = body as { + reason?: string; + message?: string; + label?: string; + prefillEnvVars?: string[]; + prefillSkills?: PrefillSkill[]; + prefillMcpServers?: PrefillMcpServer[]; + prefillNixPackages?: string[]; + prefillGrants?: string[]; + }; - const agentId = worker.agentId; - const userId = worker.userId; - const platform = worker.platform || "unknown"; + const agentId = worker.agentId; + const userId = worker.userId; + const platform = worker.platform || "unknown"; - if (!agentId) { - logger.error("Missing agentId in worker token", { worker }); - return c.json({ error: "Missing agentId in worker context" }, 400); - } + if (!agentId) { + logger.error("Missing agentId in worker token", { worker }); + return c.json({ error: "Missing agentId in worker context" }, 400); + } - logger.info("Generating settings link", { - agentId, - userId, - platform, - reason: reason?.substring(0, 100), - hasMessage: !!message, - prefillEnvVarsCount: prefillEnvVars?.length || 0, - prefillSkillsCount: prefillSkills?.length || 0, - prefillMcpServersCount: prefillMcpServers?.length || 0, - prefillNixPackagesCount: prefillNixPackages?.length || 0, - prefillGrantsCount: prefillGrants?.length || 0, - }); + logger.info("Generating settings link", { + agentId, + userId, + platform, + reason: reason?.substring(0, 100), + hasMessage: !!message, + prefillEnvVarsCount: prefillEnvVars?.length || 0, + prefillSkillsCount: prefillSkills?.length || 0, + prefillMcpServersCount: prefillMcpServers?.length || 0, + prefillNixPackagesCount: prefillNixPackages?.length || 0, + prefillGrantsCount: prefillGrants?.length || 0, + }); - // Domain-only requests can use inline approval buttons - const isDomainOnly = - prefillGrants && - prefillGrants.length > 0 && - !prefillSkills?.length && - !prefillMcpServers?.length && - !prefillEnvVars?.length && - !prefillNixPackages?.length; + // Domain-only requests can use inline approval buttons + const isDomainOnly = + prefillGrants && + prefillGrants.length > 0 && + !prefillSkills?.length && + !prefillMcpServers?.length && + !prefillEnvVars?.length && + !prefillNixPackages?.length; - if (isDomainOnly && interactionService && grantStore) { - logger.info("Using inline grant approval", { - agentId, - domains: prefillGrants, - }); + if (isDomainOnly && interactionService && grantStore) { + logger.info("Using inline grant approval", { + agentId, + domains: prefillGrants, + }); - await interactionService.postGrantRequest( - userId, - agentId, - worker.conversationId, - worker.channelId, - worker.teamId, - prefillGrants, - reason || "Domain access requested" - ); + await interactionService.postGrantRequest( + userId, + agentId, + worker.conversationId, + worker.channelId, + worker.teamId, + prefillGrants, + reason || "Domain access requested", + ); - return c.json({ - type: "inline_grant", - message: - "Approval buttons sent to user in chat. The user will approve or deny the request.", - }); - } + return c.json({ + type: "inline_grant", + message: + "Approval buttons sent to user in chat. The user will approve or deny the request.", + }); + } - // Telegram plain "Open Settings" links use stable URLs (no session needed) - const hasPrefillData = - prefillSkills?.length || - prefillMcpServers?.length || - prefillEnvVars?.length || - prefillNixPackages?.length || - prefillGrants?.length || - message; + // Telegram plain "Open Settings" links use stable URLs (no session needed) + const hasPrefillData = + prefillSkills?.length || + prefillMcpServers?.length || + prefillEnvVars?.length || + prefillNixPackages?.length || + prefillGrants?.length || + message; - if (platform === "telegram" && !hasPrefillData && interactionService) { - const stableUrl = buildTelegramSettingsUrl(worker.channelId); - const buttonLabel = label || "Open Settings"; + if (platform === "telegram" && !hasPrefillData && interactionService) { + const stableUrl = buildTelegramSettingsUrl(worker.channelId); + const buttonLabel = label || "Open Settings"; - await interactionService.postLinkButton( - userId, - worker.conversationId, - worker.channelId, - worker.teamId, - platform, - stableUrl, - buttonLabel, - "settings" - ); + await interactionService.postLinkButton( + userId, + worker.conversationId, + worker.channelId, + worker.teamId, + platform, + stableUrl, + buttonLabel, + "settings", + ); - return c.json({ - type: "settings_link", - message: "Settings link sent as a button to the user.", - }); - } + return c.json({ + type: "settings_link", + message: "Settings link sent as a button to the user.", + }); + } - // Create server-side session (no encrypted token in URL) - const ttlMs = getSettingsTokenTtlMs(); - const { sessionId, expiresAt } = await sessionStore.createSession( - { - userId, - platform, - agentId, - channelId: worker.channelId, - teamId: worker.teamId, - message, - prefillEnvVars, - prefillSkills, - prefillMcpServers, - prefillNixPackages, - prefillGrants, - sourceContext: { - conversationId: worker.conversationId, - channelId: worker.channelId, - teamId: worker.teamId, - platform, - }, - }, - ttlMs - ); + // Create server-side session (no encrypted token in URL) + const ttlMs = getSettingsTokenTtlMs(); + const { sessionId, expiresAt } = await sessionStore.createSession( + { + userId, + platform, + agentId, + channelId: worker.channelId, + teamId: worker.teamId, + message, + prefillEnvVars, + prefillSkills, + prefillMcpServers, + prefillNixPackages, + prefillGrants, + sourceContext: { + conversationId: worker.conversationId, + channelId: worker.channelId, + teamId: worker.teamId, + platform, + }, + }, + ttlMs, + ); - // Telegram web_app buttons replace URL hash fragments, so use query param - const url = buildSessionUrl(sessionId, { - useQueryParam: platform === "telegram", - }); + const url = buildSessionUrl(sessionId); - logger.info("Settings link generated (session-based)", { - agentId, - userId, - expiresAt: new Date(expiresAt).toISOString(), - }); + logger.info("Settings link generated (session-based)", { + agentId, + userId, + expiresAt: new Date(expiresAt).toISOString(), + }); - // Fire link button event so platforms render natively - if (interactionService) { - const buttonLabel = - label || - (prefillMcpServers?.length - ? `Install ${prefillMcpServers[0]?.name || "MCP Server"}` - : prefillSkills?.length - ? "Install Skill" - : "Open Settings"); + // Fire link button event so platforms render natively + if (interactionService) { + const buttonLabel = + label || + (prefillMcpServers?.length + ? `Install ${prefillMcpServers[0]?.name || "MCP Server"}` + : prefillSkills?.length + ? "Install Skill" + : "Open Settings"); - await interactionService.postLinkButton( - userId, - worker.conversationId, - worker.channelId, - worker.teamId, - platform, - url, - buttonLabel, - prefillSkills?.length || prefillMcpServers?.length - ? "install" - : "settings" - ); + await interactionService.postLinkButton( + userId, + worker.conversationId, + worker.channelId, + worker.teamId, + platform, + url, + buttonLabel, + prefillSkills?.length || prefillMcpServers?.length + ? "install" + : "settings", + ); - return c.json({ - type: "settings_link", - message: "Settings link sent as a button to the user.", - }); - } + return c.json({ + type: "settings_link", + message: "Settings link sent as a button to the user.", + }); + } - // Fallback: no interaction service (shouldn't happen in practice). - // Never return the raw URL to the worker. - logger.warn( - "No interactionService available — settings link generated but cannot be delivered to user", - { agentId, userId } - ); - return c.json({ - type: "settings_link", - message: - "Settings link generated but could not be delivered (no interaction service).", - }); - } catch (error) { - logger.error("Failed to generate settings link", { error }); - return c.json( - { - error: - error instanceof Error - ? error.message - : "Failed to generate settings link", - }, - 500 - ); - } - }); + // Fallback: no interaction service (shouldn't happen in practice). + // Never return the raw URL to the worker. + logger.warn( + "No interactionService available — settings link generated but cannot be delivered to user", + { agentId, userId }, + ); + return c.json({ + type: "settings_link", + message: + "Settings link generated but could not be delivered (no interaction service).", + }); + } catch (error) { + logger.error("Failed to generate settings link", { error }); + return c.json( + { + error: + error instanceof Error + ? error.message + : "Failed to generate settings link", + }, + 500, + ); + } + }); - logger.info("Internal settings link routes registered"); + logger.info("Internal settings link routes registered"); - return router; + return router; } diff --git a/packages/gateway/src/routes/public/agent-config.ts b/packages/gateway/src/routes/public/agent-config.ts index 03d9ec3d5..bb4714e78 100644 --- a/packages/gateway/src/routes/public/agent-config.ts +++ b/packages/gateway/src/routes/public/agent-config.ts @@ -6,11 +6,11 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { - type AgentIntegrationConfig, - type AuthProfile, - createLogger, - normalizeSkillIntegration, - type SkillConfig, + type AgentIntegrationConfig, + type AuthProfile, + createLogger, + normalizeSkillIntegration, + type SkillConfig, } from "@lobu/core"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; import type { ProviderCatalogService } from "../../auth/provider-catalog"; @@ -18,7 +18,7 @@ import { collectModelValues } from "../../auth/provider-model-options"; import type { ProviderStatus } from "../../auth/provider-status"; import type { AgentSettings, AgentSettingsStore } from "../../auth/settings"; import type { AuthProfilesManager } from "../../auth/settings/auth-profiles-manager"; -import type { SettingsTokenPayload } from "../../auth/settings/token-service"; +import type { SettingsSessionPayload } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { WorkerConnectionManager } from "../../gateway/connection-manager"; import type { IMessageQueue } from "../../infrastructure/queue"; @@ -32,1100 +32,1109 @@ const logger = createLogger("agent-config-routes"); const REDACTED_VALUE = "__LOBU_REDACTED__"; export interface ConfigChangeEntry { - category: - | "mcp" - | "provider" - | "model" - | "packages" - | "skills" - | "instructions" - | "env" - | "plugins" - | "logging"; - action: "added" | "removed" | "updated" | "reordered"; - summary: string; - details?: string[]; + category: + | "mcp" + | "provider" + | "model" + | "packages" + | "skills" + | "instructions" + | "env" + | "plugins" + | "logging"; + action: "added" | "removed" | "updated" | "reordered"; + summary: string; + details?: string[]; } const SENSITIVE_KEY_PATTERN = - /(?:credential|secret|token|password|api(?:_|-)?key|authorization)/i; + /(?:credential|secret|token|password|api(?:_|-)?key|authorization)/i; type SanitizedAuthProfile = Omit & { - credential: string; - credentialRedacted: true; - metadata?: Omit, "refreshToken"> & { - refreshToken?: string; - refreshTokenRedacted?: true; - }; + credential: string; + credentialRedacted: true; + metadata?: Omit, "refreshToken"> & { + refreshToken?: string; + refreshTokenRedacted?: true; + }; }; type PublicAgentSettings = Omit & { - authProfiles?: SanitizedAuthProfile[]; + authProfiles?: SanitizedAuthProfile[]; }; // --- Route Definitions --- const getConfigRoute = createRoute({ - method: "get", - path: "/", - tags: [TAG], - summary: "Get agent configuration", - request: { query: TokenQuery }, - responses: { - 200: { - description: "Configuration", - content: { - "application/json": { - schema: z.object({ - agentId: z.string(), - settings: z.any(), - providers: z.record( - z.string(), - z.object({ - connected: z.boolean(), - userConnected: z.boolean(), - systemConnected: z.boolean(), - }) - ), - }), - }, - }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, + method: "get", + path: "/", + tags: [TAG], + summary: "Get agent configuration", + request: { query: TokenQuery }, + responses: { + 200: { + description: "Configuration", + content: { + "application/json": { + schema: z.object({ + agentId: z.string(), + settings: z.any(), + providers: z.record( + z.string(), + z.object({ + connected: z.boolean(), + userConnected: z.boolean(), + systemConnected: z.boolean(), + }), + ), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponse } }, + }, + }, }); const updateConfigRoute = createRoute({ - method: "patch", - path: "/", - tags: [TAG], - summary: "Update agent configuration", - request: { - query: TokenQuery, - body: { - content: { - "application/json": { - schema: z.object({ - model: z.string().optional(), - soulMd: z.string().optional(), - userMd: z.string().optional(), - identityMd: z.string().optional(), - nixConfig: z - .object({ - flakeUrl: z.string().optional(), - packages: z.array(z.string()).optional(), - }) - .nullable() - .optional(), - mcpServers: z.record(z.string(), z.any()).optional(), - skillsConfig: z - .object({ - skills: z.array( - z.object({ - repo: z.string(), - name: z.string(), - description: z.string(), - enabled: z.boolean(), - content: z.string().optional(), - contentFetchedAt: z.number().optional(), - }) - ), - }) - .optional(), - pluginsConfig: z - .object({ - plugins: z.array( - z.object({ - source: z.string(), - slot: z.enum(["tool", "provider", "memory"]), - enabled: z.boolean().optional(), - config: z.record(z.string(), z.any()).optional(), - }) - ), - }) - .optional(), - verboseLogging: z.boolean().optional(), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "Updated", - content: { - "application/json": { - schema: z.object({ success: z.boolean(), agentId: z.string() }), - }, - }, - }, - 400: { - description: "Invalid", - content: { "application/json": { schema: ErrorResponse } }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, + method: "patch", + path: "/", + tags: [TAG], + summary: "Update agent configuration", + request: { + query: TokenQuery, + body: { + content: { + "application/json": { + schema: z.object({ + model: z.string().optional(), + soulMd: z.string().optional(), + userMd: z.string().optional(), + identityMd: z.string().optional(), + nixConfig: z + .object({ + flakeUrl: z.string().optional(), + packages: z.array(z.string()).optional(), + }) + .nullable() + .optional(), + mcpServers: z.record(z.string(), z.any()).optional(), + skillsConfig: z + .object({ + skills: z.array( + z.object({ + repo: z.string(), + name: z.string(), + description: z.string(), + enabled: z.boolean(), + content: z.string().optional(), + contentFetchedAt: z.number().optional(), + }), + ), + }) + .optional(), + pluginsConfig: z + .object({ + plugins: z.array( + z.object({ + source: z.string(), + slot: z.enum(["tool", "provider", "memory"]), + enabled: z.boolean().optional(), + config: z.record(z.string(), z.any()).optional(), + }), + ), + }) + .optional(), + verboseLogging: z.boolean().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Updated", + content: { + "application/json": { + schema: z.object({ success: z.boolean(), agentId: z.string() }), + }, + }, + }, + 400: { + description: "Invalid", + content: { "application/json": { schema: ErrorResponse } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponse } }, + }, + }, }); export interface ProviderCredentialStore { - hasCredentials(agentId: string): Promise; + hasCredentials(agentId: string): Promise; } interface NixPackageSuggestion { - name: string; - pname?: string; - description?: string; + name: string; + pname?: string; + description?: string; } interface NixSearchContext { - username: string; - password: string; - alias: string; - expiresAt: number; + username: string; + password: string; + alias: string; + expiresAt: number; } let nixSearchContextCache: NixSearchContext | null = null; export interface AgentConfigRoutesConfig { - agentSettingsStore: AgentSettingsStore; - userAgentsStore?: UserAgentsStore; - agentMetadataStore?: AgentMetadataStore; - providerStores?: Record; - /** - * Provider connectivity overrides (e.g., system token means "connected" even if no user credentials are stored). - */ - providerConnectedOverrides?: Record; - providerCatalogService?: ProviderCatalogService; - authProfilesManager?: AuthProfilesManager; - queue?: IMessageQueue; - connectionManager?: WorkerConnectionManager; - grantStore?: GrantStore; + agentSettingsStore: AgentSettingsStore; + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: AgentMetadataStore; + providerStores?: Record; + /** + * Provider connectivity overrides (e.g., system token means "connected" even if no user credentials are stored). + */ + providerConnectedOverrides?: Record; + providerCatalogService?: ProviderCatalogService; + authProfilesManager?: AuthProfilesManager; + queue?: IMessageQueue; + connectionManager?: WorkerConnectionManager; + grantStore?: GrantStore; } function buildConfigChanges( - existing: AgentSettings | null, - updates: Partial + existing: AgentSettings | null, + updates: Partial, ): ConfigChangeEntry[] { - const changes: ConfigChangeEntry[] = []; - - // MCP servers - if (updates.mcpServers !== undefined) { - const oldIds = new Set(Object.keys(existing?.mcpServers || {})); - const newIds = new Set(Object.keys(updates.mcpServers || {})); - for (const id of newIds) { - if (!oldIds.has(id)) { - changes.push({ - category: "mcp", - action: "added", - summary: `MCP server "${id}" installed`, - }); - } - } - for (const id of oldIds) { - if (!newIds.has(id)) { - changes.push({ - category: "mcp", - action: "removed", - summary: `MCP server "${id}" removed`, - }); - } - } - // Check for updates on existing servers - for (const id of newIds) { - if (oldIds.has(id)) { - const oldCfg = JSON.stringify(existing?.mcpServers?.[id] || {}); - const newCfg = JSON.stringify(updates.mcpServers?.[id] || {}); - if (oldCfg !== newCfg) { - changes.push({ - category: "mcp", - action: "updated", - summary: `MCP server "${id}" updated`, - }); - } - } - } - } - - // Nix packages - if (updates.nixConfig !== undefined) { - const oldPkgs = existing?.nixConfig?.packages || []; - const newPkgs = updates.nixConfig?.packages || []; - const added = newPkgs.filter((p) => !oldPkgs.includes(p)); - const removed = oldPkgs.filter((p) => !newPkgs.includes(p)); - if (added.length > 0 || removed.length > 0) { - const details: string[] = []; - if (added.length > 0) details.push(`Added: ${added.join(", ")}`); - if (removed.length > 0) details.push(`Removed: ${removed.join(", ")}`); - changes.push({ - category: "packages", - action: added.length > 0 ? "updated" : "removed", - summary: "System packages updated", - details, - }); - } - } - - // Model - if (updates.model !== undefined && updates.model !== existing?.model) { - changes.push({ - category: "model", - action: "updated", - summary: updates.model - ? `Model changed to "${updates.model}"` - : "Model reset to default", - }); - } - - // Skills - if (updates.skillsConfig !== undefined) { - const oldSkills = (existing?.skillsConfig?.skills || []).filter( - (s) => s.enabled - ); - const newSkills = (updates.skillsConfig?.skills || []).filter( - (s) => s.enabled - ); - const oldNames = new Set(oldSkills.map((s) => s.name)); - const newNames = new Set(newSkills.map((s) => s.name)); - const added = [...newNames].filter((n) => !oldNames.has(n)); - const removed = [...oldNames].filter((n) => !newNames.has(n)); - if (added.length > 0 || removed.length > 0) { - const details: string[] = []; - if (added.length > 0) details.push(`Enabled: ${added.join(", ")}`); - if (removed.length > 0) details.push(`Disabled: ${removed.join(", ")}`); - changes.push({ - category: "skills", - action: "updated", - summary: "Skills configuration updated", - details, - }); - } - } - - // Instructions (soulMd, userMd, identityMd) - if ( - (updates.soulMd !== undefined && updates.soulMd !== existing?.soulMd) || - (updates.userMd !== undefined && updates.userMd !== existing?.userMd) || - (updates.identityMd !== undefined && - updates.identityMd !== existing?.identityMd) - ) { - changes.push({ - category: "instructions", - action: "updated", - summary: "Agent instructions updated", - }); - } - - // Plugins - if (updates.pluginsConfig !== undefined) { - changes.push({ - category: "plugins", - action: "updated", - summary: "Plugins configuration updated", - }); - } - - // Verbose logging - if ( - updates.verboseLogging !== undefined && - updates.verboseLogging !== existing?.verboseLogging - ) { - changes.push({ - category: "logging", - action: "updated", - summary: `Verbose logging ${updates.verboseLogging ? "enabled" : "disabled"}`, - }); - } - - return changes; + const changes: ConfigChangeEntry[] = []; + + // MCP servers + if (updates.mcpServers !== undefined) { + const oldIds = new Set(Object.keys(existing?.mcpServers || {})); + const newIds = new Set(Object.keys(updates.mcpServers || {})); + for (const id of newIds) { + if (!oldIds.has(id)) { + changes.push({ + category: "mcp", + action: "added", + summary: `MCP server "${id}" installed`, + }); + } + } + for (const id of oldIds) { + if (!newIds.has(id)) { + changes.push({ + category: "mcp", + action: "removed", + summary: `MCP server "${id}" removed`, + }); + } + } + // Check for updates on existing servers + for (const id of newIds) { + if (oldIds.has(id)) { + const oldCfg = JSON.stringify(existing?.mcpServers?.[id] || {}); + const newCfg = JSON.stringify(updates.mcpServers?.[id] || {}); + if (oldCfg !== newCfg) { + changes.push({ + category: "mcp", + action: "updated", + summary: `MCP server "${id}" updated`, + }); + } + } + } + } + + // Nix packages + if (updates.nixConfig !== undefined) { + const oldPkgs = existing?.nixConfig?.packages || []; + const newPkgs = updates.nixConfig?.packages || []; + const added = newPkgs.filter((p) => !oldPkgs.includes(p)); + const removed = oldPkgs.filter((p) => !newPkgs.includes(p)); + if (added.length > 0 || removed.length > 0) { + const details: string[] = []; + if (added.length > 0) details.push(`Added: ${added.join(", ")}`); + if (removed.length > 0) details.push(`Removed: ${removed.join(", ")}`); + changes.push({ + category: "packages", + action: added.length > 0 ? "updated" : "removed", + summary: "System packages updated", + details, + }); + } + } + + // Model + if (updates.model !== undefined && updates.model !== existing?.model) { + changes.push({ + category: "model", + action: "updated", + summary: updates.model + ? `Model changed to "${updates.model}"` + : "Model reset to default", + }); + } + + // Skills + if (updates.skillsConfig !== undefined) { + const oldSkills = (existing?.skillsConfig?.skills || []).filter( + (s) => s.enabled, + ); + const newSkills = (updates.skillsConfig?.skills || []).filter( + (s) => s.enabled, + ); + const oldNames = new Set(oldSkills.map((s) => s.name)); + const newNames = new Set(newSkills.map((s) => s.name)); + const added = [...newNames].filter((n) => !oldNames.has(n)); + const removed = [...oldNames].filter((n) => !newNames.has(n)); + if (added.length > 0 || removed.length > 0) { + const details: string[] = []; + if (added.length > 0) details.push(`Enabled: ${added.join(", ")}`); + if (removed.length > 0) details.push(`Disabled: ${removed.join(", ")}`); + changes.push({ + category: "skills", + action: "updated", + summary: "Skills configuration updated", + details, + }); + } + } + + // Instructions (soulMd, userMd, identityMd) + if ( + (updates.soulMd !== undefined && updates.soulMd !== existing?.soulMd) || + (updates.userMd !== undefined && updates.userMd !== existing?.userMd) || + (updates.identityMd !== undefined && + updates.identityMd !== existing?.identityMd) + ) { + changes.push({ + category: "instructions", + action: "updated", + summary: "Agent instructions updated", + }); + } + + // Plugins + if (updates.pluginsConfig !== undefined) { + changes.push({ + category: "plugins", + action: "updated", + summary: "Plugins configuration updated", + }); + } + + // Verbose logging + if ( + updates.verboseLogging !== undefined && + updates.verboseLogging !== existing?.verboseLogging + ) { + changes.push({ + category: "logging", + action: "updated", + summary: `Verbose logging ${updates.verboseLogging ? "enabled" : "disabled"}`, + }); + } + + return changes; } export function createAgentConfigRoutes( - config: AgentConfigRoutesConfig + config: AgentConfigRoutesConfig, ): OpenAPIHono { - const app = new OpenAPIHono(); - - /** - * Verify settings token against agentId. - * If token has agentId, it must match. If token has no agentId (channel-based), - * verify user owns the agent via userAgentsStore index or canonical metadata owner. - */ - const verifyToken = async ( - payload: SettingsTokenPayload | null, - agentId: string - ): Promise => { - if (!payload) return null; - - if (payload.agentId) { - if (payload.agentId !== agentId) return null; - } else { - // Channel-based token: check ownership - const owns = config.userAgentsStore - ? await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ) - : false; - - if (!owns) { - if (!config.agentMetadataStore) return null; - const metadata = await config.agentMetadataStore.getMetadata(agentId); - const isOwner = - metadata?.owner?.platform === payload.platform && - metadata?.owner?.userId === payload.userId; - if (!isOwner && !metadata?.isWorkspaceAgent) return null; - - // Reconcile: metadata says owner but index is missing — repair it - if (isOwner && config.userAgentsStore) { - config.userAgentsStore - .addAgent(payload.platform, payload.userId, agentId) - .catch(() => { - /* best-effort reconciliation */ - }); - } - } - } - return payload; - }; - - app.openapi(getConfigRoute, async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - const settings = await config.agentSettingsStore.getSettings(agentId); - - // Provider status - const providers: Record = {}; - if (config.providerStores) { - for (const [name, store] of Object.entries(config.providerStores)) { - try { - const hasSystemCredentials = - config.providerConnectedOverrides?.[name] === true; - const hasUserCredentials = await store.hasCredentials(agentId); - - const profiles = config.authProfilesManager - ? await config.authProfilesManager.getProviderProfiles( - agentId, - name - ) - : []; - const now = Date.now(); - const validProfiles = profiles.filter( - (p) => !p.metadata?.expiresAt || p.metadata.expiresAt > now - ); - - providers[name] = { - connected: hasUserCredentials || hasSystemCredentials, - userConnected: hasUserCredentials, - systemConnected: hasSystemCredentials, - activeAuthType: validProfiles[0]?.authType, - authMethods: validProfiles.map((p, i) => ({ - profileId: p.id, - authType: p.authType, - label: p.label, - isPrimary: i === 0, - })), - }; - } catch { - providers[name] = { - connected: false, - userConnected: false, - systemConnected: false, - }; - } - } - } - - return c.json({ - agentId, - settings: sanitizeSettingsForResponse(settings), - providers, - }); - }); - - app.openapi(updateConfigRoute, async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - try { - const existingSettings = - await config.agentSettingsStore.getSettings(agentId); - const availableModels = await collectModelValues(agentId, payload.userId); - const body = restoreRedactedSentinels( - c.req.valid("json"), - existingSettings || {} - ); - - const updates: Partial = {}; - - // Handle explicit null for nixConfig (clear) - if (body.nixConfig === null) { - updates.nixConfig = undefined; - delete body.nixConfig; - } - - if (Object.keys(body).length > 0) { - const validated = await validateSettings( - body as Partial, - availableModels - ); - Object.assign(updates, validated); - } - - if (Object.keys(updates).length > 0) { - const changes = buildConfigChanges(existingSettings, updates); - await config.agentSettingsStore.updateSettings(agentId, updates); - - // Notify active workers of config changes - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes, - }); - } - - // Auto-register/cleanup integrations when skills change - if (updates.skillsConfig) { - await autoRegisterSkillIntegrations( - config.agentSettingsStore, - agentId, - updates.skillsConfig.skills || [] - ); - - // Cleanup orphaned dependencies from removed/disabled skills - await cleanupOrphanedSkillDependencies( - config.agentSettingsStore, - agentId, - existingSettings?.skillsConfig?.skills || [], - updates.skillsConfig.skills || [] - ); - } - - if (body.mcpServers && config.queue && payload.sourceContext) { - await maybeSendMcpInstalledNotifications({ - queue: config.queue, - agentSettingsStore: config.agentSettingsStore, - agentId, - userId: payload.userId, - platform: payload.sourceContext.platform || payload.platform, - channelId: payload.sourceContext.channelId, - conversationId: payload.sourceContext.conversationId, - teamId: payload.sourceContext.teamId, - previousSettings: existingSettings, - nextMcpServers: updates.mcpServers || existingSettings?.mcpServers, - }); - } - - return c.json({ success: true, agentId }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : "Invalid" }, 400); - } - }); - - // GET /packages/search?q=python - app.get("/packages/search", async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - const query = (c.req.query("q") || "").trim(); - if (query.length < 2) return c.json({ packages: [] }); - - try { - const packages = await searchNixPackages(query); - return c.json({ packages }); - } catch (error) { - logger.warn("Nix package search failed", { - error: error instanceof Error ? error.message : String(error), - }); - return c.json({ packages: [] }); - } - }); - - // --- Provider Catalog Endpoints --- - - // GET /providers/catalog - app.get("/providers/catalog", async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - if (!config.providerCatalogService) { - return c.json({ error: "Provider catalog not available" }, 503); - } - - const allProviders = config.providerCatalogService.listCatalogProviders(); - const installed = - await config.providerCatalogService.getInstalledProviders(agentId); - const installedIds = new Set(installed.map((ip) => ip.providerId)); - - const catalog = allProviders.map((p) => ({ - providerId: p.providerId, - name: p.providerDisplayName, - iconUrl: p.providerIconUrl || "", - authType: p.authType || "api-key", - description: p.catalogDescription || "", - installed: installedIds.has(p.providerId), - })); - - return c.json({ catalog, installedProviders: installed }); - }); - - // PUT /providers/:providerId - Install (enabled: true) or uninstall (enabled: false) a provider - app.put("/providers/:providerId", async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const providerId = c.req.param("providerId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - if (!config.providerCatalogService) { - return c.json({ error: "Provider catalog not available" }, 503); - } - - if (!providerId) { - return c.json({ error: "providerId is required" }, 400); - } - - try { - const body = await c.req.json(); - const { enabled, config: providerConfig } = body; - - if (enabled === false) { - await config.providerCatalogService.uninstallProvider( - agentId, - providerId.trim() - ); - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes: [ - { - category: "provider", - action: "removed", - summary: `Provider "${providerId.trim()}" removed`, - }, - ] satisfies ConfigChangeEntry[], - }); - } else { - await config.providerCatalogService.installProvider( - agentId, - providerId.trim(), - providerConfig - ); - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes: [ - { - category: "provider", - action: "added", - summary: `Provider "${providerId.trim()}" installed`, - }, - ] satisfies ConfigChangeEntry[], - }); - } - - return c.json({ success: true, agentId }); - } catch (e) { - return c.json( - { error: e instanceof Error ? e.message : "Operation failed" }, - 400 - ); - } - }); - - // PATCH /providers/reorder - app.patch("/providers/reorder", async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - if (!config.providerCatalogService) { - return c.json({ error: "Provider catalog not available" }, 503); - } - - try { - const body = await c.req.json(); - const { providerIds } = body; - if (!Array.isArray(providerIds)) { - return c.json({ error: "providerIds array is required" }, 400); - } - - const orderedIds = providerIds.filter( - (id): id is string => typeof id === "string" - ); - await config.providerCatalogService.reorderProviders(agentId, orderedIds); - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes: [ - { - category: "provider", - action: "reordered", - summary: `Provider priority: ${orderedIds.join(" > ")}`, - }, - ] satisfies ConfigChangeEntry[], - }); - return c.json({ success: true, agentId }); - } catch (e) { - return c.json( - { error: e instanceof Error ? e.message : "Reorder failed" }, - 400 - ); - } - }); - - // ===== Grant Endpoints ===== - - if (config.grantStore) { - const grantStore = config.grantStore; - - // GET /grants - List all active grants - app.get("/grants", async (c) => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - const grants = await grantStore.listGrants(agentId); - return c.json(grants); - }); - - // POST /grants - Create a grant - app.post("/grants", async (c) => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - const body = await c.req.json<{ - pattern: string; - expiresAt: number | null; - denied?: boolean; - }>(); - if (!body.pattern) { - return c.json({ error: "pattern is required" }, 400); - } - - await grantStore.grant( - agentId, - body.pattern, - body.expiresAt ?? null, - body.denied - ); - logger.info("Grant created via settings API", { - agentId, - pattern: body.pattern, - expiresAt: body.expiresAt, - }); - return c.json({ success: true }); - }); - - // DELETE /grants/:pattern - Revoke a grant - app.delete("/grants/:pattern", async (c) => { - const agentId = c.req.param("agentId") || ""; - const pattern = decodeURIComponent(c.req.param("pattern") || ""); - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - await grantStore.revoke(agentId, pattern); - logger.info("Grant revoked via settings API", { agentId, pattern }); - return c.json({ success: true }); - }); - } - - return app; + const app = new OpenAPIHono(); + + /** + * Verify settings token against agentId. + * If token has agentId, it must match. If token has no agentId (channel-based), + * verify user owns the agent via userAgentsStore index or canonical metadata owner. + */ + const verifyToken = async ( + payload: SettingsSessionPayload | null, + agentId: string, + ): Promise => { + if (!payload) return null; + + if (payload.agentId) { + if (payload.agentId !== agentId) return null; + } else { + // Channel-based token: check ownership + const owns = config.userAgentsStore + ? await config.userAgentsStore.ownsAgent( + payload.platform, + payload.userId, + agentId, + ) + : false; + + if (!owns) { + if (!config.agentMetadataStore) return null; + const metadata = await config.agentMetadataStore.getMetadata(agentId); + const isOwner = + metadata?.owner?.platform === payload.platform && + metadata?.owner?.userId === payload.userId; + if (!isOwner && !metadata?.isWorkspaceAgent) return null; + + // Reconcile: metadata says owner but index is missing — repair it + if (isOwner && config.userAgentsStore) { + config.userAgentsStore + .addAgent(payload.platform, payload.userId, agentId) + .catch(() => { + /* best-effort reconciliation */ + }); + } + } + } + return payload; + }; + + app.openapi(getConfigRoute, async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + const settings = await config.agentSettingsStore.getSettings(agentId); + + // Provider status + const providers: Record = {}; + if (config.providerStores) { + for (const [name, store] of Object.entries(config.providerStores)) { + try { + const hasSystemCredentials = + config.providerConnectedOverrides?.[name] === true; + const hasUserCredentials = await store.hasCredentials(agentId); + + const profiles = config.authProfilesManager + ? await config.authProfilesManager.getProviderProfiles( + agentId, + name, + ) + : []; + const now = Date.now(); + const validProfiles = profiles.filter( + (p) => !p.metadata?.expiresAt || p.metadata.expiresAt > now, + ); + + providers[name] = { + connected: hasUserCredentials || hasSystemCredentials, + userConnected: hasUserCredentials, + systemConnected: hasSystemCredentials, + activeAuthType: validProfiles[0]?.authType, + authMethods: validProfiles.map((p, i) => ({ + profileId: p.id, + authType: p.authType, + label: p.label, + isPrimary: i === 0, + })), + }; + } catch { + providers[name] = { + connected: false, + userConnected: false, + systemConnected: false, + }; + } + } + } + + return c.json({ + agentId, + settings: sanitizeSettingsForResponse(settings), + providers, + }); + }); + + app.openapi(updateConfigRoute, async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + try { + const existingSettings = + await config.agentSettingsStore.getSettings(agentId); + const availableModels = await collectModelValues(agentId, payload.userId); + const body = restoreRedactedSentinels( + c.req.valid("json"), + existingSettings || {}, + ); + + const updates: Partial = {}; + + // Handle explicit null for nixConfig (clear) + if (body.nixConfig === null) { + updates.nixConfig = undefined; + delete body.nixConfig; + } + + if (Object.keys(body).length > 0) { + const validated = await validateSettings( + body as Partial, + availableModels, + ); + Object.assign(updates, validated); + } + + if (Object.keys(updates).length > 0) { + const changes = buildConfigChanges(existingSettings, updates); + await config.agentSettingsStore.updateSettings(agentId, updates); + + // Notify active workers of config changes + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes, + }); + } + + // Auto-register/cleanup integrations when skills change + if (updates.skillsConfig) { + await autoRegisterSkillIntegrations( + config.agentSettingsStore, + agentId, + updates.skillsConfig.skills || [], + ); + + // Cleanup orphaned dependencies from removed/disabled skills + await cleanupOrphanedSkillDependencies( + config.agentSettingsStore, + agentId, + existingSettings?.skillsConfig?.skills || [], + updates.skillsConfig.skills || [], + ); + } + + if (body.mcpServers && config.queue && payload.sourceContext) { + await maybeSendMcpInstalledNotifications({ + queue: config.queue, + agentSettingsStore: config.agentSettingsStore, + agentId, + userId: payload.userId, + platform: payload.sourceContext.platform || payload.platform, + channelId: payload.sourceContext.channelId, + conversationId: payload.sourceContext.conversationId, + teamId: payload.sourceContext.teamId, + previousSettings: existingSettings, + nextMcpServers: updates.mcpServers || existingSettings?.mcpServers, + }); + } + + return c.json({ success: true, agentId }); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "Invalid" }, 400); + } + }); + + // GET /packages/search?q=python + app.get("/packages/search", async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + const query = (c.req.query("q") || "").trim(); + if (query.length < 2) return c.json({ packages: [] }); + + try { + const packages = await searchNixPackages(query); + return c.json({ packages }); + } catch (error) { + logger.warn("Nix package search failed", { + error: error instanceof Error ? error.message : String(error), + }); + return c.json({ packages: [] }); + } + }); + + // --- Provider Catalog Endpoints --- + + // GET /providers/catalog + app.get("/providers/catalog", async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + if (!config.providerCatalogService) { + return c.json({ error: "Provider catalog not available" }, 503); + } + + const allProviders = config.providerCatalogService.listCatalogProviders(); + const installed = + await config.providerCatalogService.getInstalledProviders(agentId); + const installedIds = new Set(installed.map((ip) => ip.providerId)); + + const catalog = allProviders.map((p) => ({ + providerId: p.providerId, + name: p.providerDisplayName, + iconUrl: p.providerIconUrl || "", + authType: p.authType || "api-key", + description: p.catalogDescription || "", + installed: installedIds.has(p.providerId), + })); + + return c.json({ catalog, installedProviders: installed }); + }); + + // PUT /providers/:providerId - Install (enabled: true) or uninstall (enabled: false) a provider + app.put("/providers/:providerId", async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const providerId = c.req.param("providerId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + if (!config.providerCatalogService) { + return c.json({ error: "Provider catalog not available" }, 503); + } + + if (!providerId) { + return c.json({ error: "providerId is required" }, 400); + } + + try { + const body = await c.req.json(); + const { enabled, config: providerConfig } = body; + + if (enabled === false) { + await config.providerCatalogService.uninstallProvider( + agentId, + providerId.trim(), + ); + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes: [ + { + category: "provider", + action: "removed", + summary: `Provider "${providerId.trim()}" removed`, + }, + ] satisfies ConfigChangeEntry[], + }); + } else { + await config.providerCatalogService.installProvider( + agentId, + providerId.trim(), + providerConfig, + ); + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes: [ + { + category: "provider", + action: "added", + summary: `Provider "${providerId.trim()}" installed`, + }, + ] satisfies ConfigChangeEntry[], + }); + } + + return c.json({ success: true, agentId }); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Operation failed" }, + 400, + ); + } + }); + + // PATCH /providers/reorder + app.patch("/providers/reorder", async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + if (!config.providerCatalogService) { + return c.json({ error: "Provider catalog not available" }, 503); + } + + try { + const body = await c.req.json(); + const { providerIds } = body; + if (!Array.isArray(providerIds)) { + return c.json({ error: "providerIds array is required" }, 400); + } + + const orderedIds = providerIds.filter( + (id): id is string => typeof id === "string", + ); + await config.providerCatalogService.reorderProviders(agentId, orderedIds); + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes: [ + { + category: "provider", + action: "reordered", + summary: `Provider priority: ${orderedIds.join(" > ")}`, + }, + ] satisfies ConfigChangeEntry[], + }); + return c.json({ success: true, agentId }); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Reorder failed" }, + 400, + ); + } + }); + + // ===== Grant Endpoints ===== + + if (config.grantStore) { + const grantStore = config.grantStore; + + // GET /grants - List all active grants + app.get("/grants", async (c) => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken( + await verifySettingsSession(c), + agentId, + ); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + const grants = await grantStore.listGrants(agentId); + return c.json(grants); + }); + + // POST /grants - Create a grant + app.post("/grants", async (c) => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken( + await verifySettingsSession(c), + agentId, + ); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + const body = await c.req.json<{ + pattern: string; + expiresAt: number | null; + denied?: boolean; + }>(); + if (!body.pattern) { + return c.json({ error: "pattern is required" }, 400); + } + + await grantStore.grant( + agentId, + body.pattern, + body.expiresAt ?? null, + body.denied, + ); + logger.info("Grant created via settings API", { + agentId, + pattern: body.pattern, + expiresAt: body.expiresAt, + }); + return c.json({ success: true }); + }); + + // DELETE /grants/:pattern - Revoke a grant + app.delete("/grants/:pattern", async (c) => { + const agentId = c.req.param("agentId") || ""; + const pattern = decodeURIComponent(c.req.param("pattern") || ""); + const payload = await verifyToken( + await verifySettingsSession(c), + agentId, + ); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + await grantStore.revoke(agentId, pattern); + logger.info("Grant revoked via settings API", { agentId, pattern }); + return c.json({ success: true }); + }); + } + + return app; } async function resolveNixSearchContext(): Promise { - if (nixSearchContextCache && nixSearchContextCache.expiresAt > Date.now()) { - return nixSearchContextCache; - } - - const bundleResp = await fetch("https://search.nixos.org/bundle.js"); - if (!bundleResp.ok) { - throw new Error(`Failed to fetch Nix search bundle: ${bundleResp.status}`); - } - const bundleText = await bundleResp.text(); - - const userMatch = bundleText.match(/elasticsearchUsername:"([^"]+)"/); - const passMatch = bundleText.match(/elasticsearchPassword:"([^"]+)"/); - const versionMatch = bundleText.match( - /elasticsearchMappingSchemaVersion:parseInt\("(\d+)"\)/ - ); - const channelsMatch = bundleText.match( - /nixosChannels:JSON\.parse\('([^']+)'\)/ - ); - - if (!userMatch?.[1] || !passMatch?.[1]) { - throw new Error("Unable to parse Nix search credentials"); - } - - let preferredAlias: string | undefined; - if (versionMatch?.[1] && channelsMatch?.[1]) { - try { - const channelsData = JSON.parse(channelsMatch[1]) as { - default?: string; - channels?: Array<{ id?: string }>; - }; - const unstableChannel = channelsData.channels?.find( - (channel) => channel.id === "unstable" - )?.id; - const channelId = unstableChannel || channelsData.default || "unstable"; - preferredAlias = `latest-${versionMatch[1]}-nixos-${channelId}`; - } catch { - preferredAlias = undefined; - } - } - - const authHeader = `Basic ${Buffer.from( - `${userMatch[1]}:${passMatch[1]}` - ).toString("base64")}`; - const aliasesResp = await fetch( - "https://search.nixos.org/backend/_cat/aliases?h=alias", - { - headers: { - Authorization: authHeader, - }, - } - ); - - let alias = preferredAlias; - if (aliasesResp.ok) { - const aliasesText = await aliasesResp.text(); - const aliases = aliasesText - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - alias = - aliases.find((value) => /^latest-\d+-nixos-unstable$/.test(value)) || - alias || - aliases.find((value) => /^latest-\d+-nixos-[\w.-]+$/.test(value)); - } - - if (!alias) { - throw new Error("Unable to resolve Nix search alias"); - } - - nixSearchContextCache = { - username: userMatch[1], - password: passMatch[1], - alias, - expiresAt: Date.now() + 10 * 60 * 1000, - }; - - return nixSearchContextCache; + if (nixSearchContextCache && nixSearchContextCache.expiresAt > Date.now()) { + return nixSearchContextCache; + } + + const bundleResp = await fetch("https://search.nixos.org/bundle.js"); + if (!bundleResp.ok) { + throw new Error(`Failed to fetch Nix search bundle: ${bundleResp.status}`); + } + const bundleText = await bundleResp.text(); + + const userMatch = bundleText.match(/elasticsearchUsername:"([^"]+)"/); + const passMatch = bundleText.match(/elasticsearchPassword:"([^"]+)"/); + const versionMatch = bundleText.match( + /elasticsearchMappingSchemaVersion:parseInt\("(\d+)"\)/, + ); + const channelsMatch = bundleText.match( + /nixosChannels:JSON\.parse\('([^']+)'\)/, + ); + + if (!userMatch?.[1] || !passMatch?.[1]) { + throw new Error("Unable to parse Nix search credentials"); + } + + let preferredAlias: string | undefined; + if (versionMatch?.[1] && channelsMatch?.[1]) { + try { + const channelsData = JSON.parse(channelsMatch[1]) as { + default?: string; + channels?: Array<{ id?: string }>; + }; + const unstableChannel = channelsData.channels?.find( + (channel) => channel.id === "unstable", + )?.id; + const channelId = unstableChannel || channelsData.default || "unstable"; + preferredAlias = `latest-${versionMatch[1]}-nixos-${channelId}`; + } catch { + preferredAlias = undefined; + } + } + + const authHeader = `Basic ${Buffer.from( + `${userMatch[1]}:${passMatch[1]}`, + ).toString("base64")}`; + const aliasesResp = await fetch( + "https://search.nixos.org/backend/_cat/aliases?h=alias", + { + headers: { + Authorization: authHeader, + }, + }, + ); + + let alias = preferredAlias; + if (aliasesResp.ok) { + const aliasesText = await aliasesResp.text(); + const aliases = aliasesText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + alias = + aliases.find((value) => /^latest-\d+-nixos-unstable$/.test(value)) || + alias || + aliases.find((value) => /^latest-\d+-nixos-[\w.-]+$/.test(value)); + } + + if (!alias) { + throw new Error("Unable to resolve Nix search alias"); + } + + nixSearchContextCache = { + username: userMatch[1], + password: passMatch[1], + alias, + expiresAt: Date.now() + 10 * 60 * 1000, + }; + + return nixSearchContextCache; } async function searchNixPackages( - query: string + query: string, ): Promise { - const trimmedQuery = query.trim(); - if (!trimmedQuery) return []; - - const runSearch = async (context: NixSearchContext) => { - const authHeader = `Basic ${Buffer.from( - `${context.username}:${context.password}` - ).toString("base64")}`; - - const searchResp = await fetch( - `https://search.nixos.org/backend/${encodeURIComponent(context.alias)}/_search`, - { - method: "POST", - headers: { - Authorization: authHeader, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - size: 10, - _source: [ - "package_attr_name", - "package_pname", - "package_description", - ], - query: { - multi_match: { - query: trimmedQuery, - fields: [ - "package_attr_name^4", - "package_pname^3", - "package_description", - ], - }, - }, - }), - } - ); - - if (!searchResp.ok) { - throw new Error(`Nix search failed: ${searchResp.status}`); - } - - const data = (await searchResp.json()) as { - hits?: { - hits?: Array<{ - _source?: { - package_attr_name?: string; - package_pname?: string; - package_description?: string; - }; - }>; - }; - }; - - const seen = new Set(); - const results: NixPackageSuggestion[] = []; - for (const hit of data.hits?.hits || []) { - const source = hit._source; - const name = source?.package_attr_name?.trim(); - if (!name || seen.has(name)) continue; - seen.add(name); - results.push({ - name, - pname: source?.package_pname || undefined, - description: source?.package_description || undefined, - }); - } - - return results; - }; - - let context = await resolveNixSearchContext(); - try { - return await runSearch(context); - } catch { - // Retry once with fresh context in case alias changed. - nixSearchContextCache = null; - context = await resolveNixSearchContext(); - return runSearch(context); - } + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const runSearch = async (context: NixSearchContext) => { + const authHeader = `Basic ${Buffer.from( + `${context.username}:${context.password}`, + ).toString("base64")}`; + + const searchResp = await fetch( + `https://search.nixos.org/backend/${encodeURIComponent(context.alias)}/_search`, + { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + size: 10, + _source: [ + "package_attr_name", + "package_pname", + "package_description", + ], + query: { + multi_match: { + query: trimmedQuery, + fields: [ + "package_attr_name^4", + "package_pname^3", + "package_description", + ], + }, + }, + }), + }, + ); + + if (!searchResp.ok) { + throw new Error(`Nix search failed: ${searchResp.status}`); + } + + const data = (await searchResp.json()) as { + hits?: { + hits?: Array<{ + _source?: { + package_attr_name?: string; + package_pname?: string; + package_description?: string; + }; + }>; + }; + }; + + const seen = new Set(); + const results: NixPackageSuggestion[] = []; + for (const hit of data.hits?.hits || []) { + const source = hit._source; + const name = source?.package_attr_name?.trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + results.push({ + name, + pname: source?.package_pname || undefined, + description: source?.package_description || undefined, + }); + } + + return results; + }; + + let context = await resolveNixSearchContext(); + try { + return await runSearch(context); + } catch { + // Retry once with fresh context in case alias changed. + nixSearchContextCache = null; + context = await resolveNixSearchContext(); + return runSearch(context); + } } function sanitizeSettingsForResponse( - settings: AgentSettings | null + settings: AgentSettings | null, ): PublicAgentSettings | Record { - if (!settings) return {}; + if (!settings) return {}; - const sanitized = redactSensitiveFields(settings) as PublicAgentSettings; + const sanitized = redactSensitiveFields(settings) as PublicAgentSettings; - if (Array.isArray(settings.authProfiles)) { - sanitized.authProfiles = settings.authProfiles.map(sanitizeAuthProfile); - } + if (Array.isArray(settings.authProfiles)) { + sanitized.authProfiles = settings.authProfiles.map(sanitizeAuthProfile); + } - return sanitized; + return sanitized; } function sanitizeAuthProfile(profile: AuthProfile): SanitizedAuthProfile { - const hadRefreshToken = !!profile.metadata?.refreshToken; - const metadata = profile.metadata - ? (redactSensitiveFields( - profile.metadata - ) as SanitizedAuthProfile["metadata"]) - : undefined; - - if (metadata && hadRefreshToken) { - metadata.refreshTokenRedacted = true; - } - - return { - ...profile, - credential: REDACTED_VALUE, - credentialRedacted: true, - metadata, - }; + const hadRefreshToken = !!profile.metadata?.refreshToken; + const metadata = profile.metadata + ? (redactSensitiveFields( + profile.metadata, + ) as SanitizedAuthProfile["metadata"]) + : undefined; + + if (metadata && hadRefreshToken) { + metadata.refreshTokenRedacted = true; + } + + return { + ...profile, + credential: REDACTED_VALUE, + credentialRedacted: true, + metadata, + }; } function redactSensitiveFields(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => redactSensitiveFields(entry)); - } - - if (!value || typeof value !== "object") { - return value; - } - - const input = value as Record; - const output: Record = {}; - - for (const [key, rawValue] of Object.entries(input)) { - if ( - typeof rawValue === "string" && - rawValue.length > 0 && - SENSITIVE_KEY_PATTERN.test(key) - ) { - output[key] = REDACTED_VALUE; - continue; - } - - output[key] = redactSensitiveFields(rawValue); - } - - return output; + if (Array.isArray(value)) { + return value.map((entry) => redactSensitiveFields(entry)); + } + + if (!value || typeof value !== "object") { + return value; + } + + const input = value as Record; + const output: Record = {}; + + for (const [key, rawValue] of Object.entries(input)) { + if ( + typeof rawValue === "string" && + rawValue.length > 0 && + SENSITIVE_KEY_PATTERN.test(key) + ) { + output[key] = REDACTED_VALUE; + continue; + } + + output[key] = redactSensitiveFields(rawValue); + } + + return output; } function restoreRedactedSentinels(input: T, previous: unknown): T { - if (input === REDACTED_VALUE && typeof previous === "string") { - return previous as T; - } - - if (Array.isArray(input)) { - const previousEntries = Array.isArray(previous) ? previous : []; - const restored = input.map((entry, index) => - restoreRedactedSentinels(entry, previousEntries[index]) - ); - return restored as T; - } - - if (!input || typeof input !== "object") { - return input; - } - - const inputObject = input as Record; - const previousObject = - previous && typeof previous === "object" - ? (previous as Record) - : {}; - const restored: Record = {}; - - for (const [key, value] of Object.entries(inputObject)) { - restored[key] = restoreRedactedSentinels(value, previousObject[key]); - } - - return restored as T; + if (input === REDACTED_VALUE && typeof previous === "string") { + return previous as T; + } + + if (Array.isArray(input)) { + const previousEntries = Array.isArray(previous) ? previous : []; + const restored = input.map((entry, index) => + restoreRedactedSentinels(entry, previousEntries[index]), + ); + return restored as T; + } + + if (!input || typeof input !== "object") { + return input; + } + + const inputObject = input as Record; + const previousObject = + previous && typeof previous === "object" + ? (previous as Record) + : {}; + const restored: Record = {}; + + for (const [key, value] of Object.entries(inputObject)) { + restored[key] = restoreRedactedSentinels(value, previousObject[key]); + } + + return restored as T; } // --- Validation --- async function validateSettings( - input: Partial, - availableModels: Set + input: Partial, + availableModels: Set, ): Promise> { - const settings: Omit = {}; - - if (typeof input.soulMd === "string") { - settings.soulMd = input.soulMd; - } - if (typeof input.userMd === "string") { - settings.userMd = input.userMd; - } - if (typeof input.identityMd === "string") { - settings.identityMd = input.identityMd; - } - - if (typeof input.model === "string") { - const cleanModel = input.model.trim(); - if (!cleanModel) { - settings.model = undefined; - } else { - if (availableModels.size === 0) { - throw new Error( - "No models are currently available from configured providers." - ); - } - if (!availableModels.has(cleanModel)) { - throw new Error(`Invalid model: ${cleanModel}`); - } - settings.model = cleanModel; - } - } - - if (input.nixConfig) { - const flakeUrl = input.nixConfig.flakeUrl?.trim(); - const packages = input.nixConfig.packages - ?.filter((pkg): pkg is string => typeof pkg === "string" && !!pkg.trim()) - .map((pkg) => pkg.trim()); - - if (!flakeUrl && (!packages || packages.length === 0)) { - throw new Error( - "nixConfig requires flakeUrl or at least one package when set" - ); - } - - settings.nixConfig = { - flakeUrl: flakeUrl || undefined, - packages: packages?.length ? packages : undefined, - }; - } - - if (input.mcpServers && typeof input.mcpServers === "object") { - settings.mcpServers = {}; - for (const [id, config] of Object.entries(input.mcpServers)) { - // Validate MCP ID format (alphanumeric, dash, underscore, starting with letter) - const cleanId = id.trim(); - if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cleanId)) { - throw new Error(`Invalid MCP ID: ${cleanId}`); - } - - // Skip if config is not an object - if (typeof config !== "object" || config === null) continue; - - const mcpConfig: Record = {}; - const cfg = config as Record; - - // Validate URL for HTTP MCPs - if (typeof cfg.url === "string") { - const url = cfg.url.trim(); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new Error( - `Invalid MCP URL for ${cleanId}: must be http:// or https://` - ); - } - mcpConfig.url = url; - } - - // Handle command-based MCPs - if (typeof cfg.command === "string") { - mcpConfig.command = cfg.command; - if (Array.isArray(cfg.args)) { - mcpConfig.args = cfg.args.filter((a) => typeof a === "string"); - } - } - - // Optional fields - if (typeof cfg.description === "string") { - mcpConfig.description = cfg.description; - } - if (typeof cfg.enabled === "boolean") { - mcpConfig.enabled = cfg.enabled; - } - - // Copy through any other config fields (oauth, headers, etc.) - for (const [key, value] of Object.entries(cfg)) { - if ( - !["url", "command", "args", "description", "enabled"].includes(key) - ) { - mcpConfig[key] = value; - } - } - - settings.mcpServers[cleanId] = mcpConfig; - } - } - - if (input.skillsConfig) { - settings.skillsConfig = input.skillsConfig; - } - - if (input.pluginsConfig) { - settings.pluginsConfig = { - plugins: input.pluginsConfig.plugins - .filter((p) => typeof p.source === "string" && p.source.trim()) - .map((p) => ({ - source: p.source.trim(), - slot: p.slot, - enabled: p.enabled ?? true, - config: - p.config && typeof p.config === "object" - ? { ...p.config } - : undefined, - })), - }; - } - - if (typeof input.verboseLogging === "boolean") { - settings.verboseLogging = input.verboseLogging; - } - - return settings; + const settings: Omit = {}; + + if (typeof input.soulMd === "string") { + settings.soulMd = input.soulMd; + } + if (typeof input.userMd === "string") { + settings.userMd = input.userMd; + } + if (typeof input.identityMd === "string") { + settings.identityMd = input.identityMd; + } + + if (typeof input.model === "string") { + const cleanModel = input.model.trim(); + if (!cleanModel) { + settings.model = undefined; + } else { + if (availableModels.size === 0) { + throw new Error( + "No models are currently available from configured providers.", + ); + } + if (!availableModels.has(cleanModel)) { + throw new Error(`Invalid model: ${cleanModel}`); + } + settings.model = cleanModel; + } + } + + if (input.nixConfig) { + const flakeUrl = input.nixConfig.flakeUrl?.trim(); + const packages = input.nixConfig.packages + ?.filter((pkg): pkg is string => typeof pkg === "string" && !!pkg.trim()) + .map((pkg) => pkg.trim()); + + if (!flakeUrl && (!packages || packages.length === 0)) { + throw new Error( + "nixConfig requires flakeUrl or at least one package when set", + ); + } + + settings.nixConfig = { + flakeUrl: flakeUrl || undefined, + packages: packages?.length ? packages : undefined, + }; + } + + if (input.mcpServers && typeof input.mcpServers === "object") { + settings.mcpServers = {}; + for (const [id, config] of Object.entries(input.mcpServers)) { + // Validate MCP ID format (alphanumeric, dash, underscore, starting with letter) + const cleanId = id.trim(); + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cleanId)) { + throw new Error(`Invalid MCP ID: ${cleanId}`); + } + + // Skip if config is not an object + if (typeof config !== "object" || config === null) continue; + + const mcpConfig: Record = {}; + const cfg = config as Record; + + // Validate URL for HTTP MCPs + if (typeof cfg.url === "string") { + const url = cfg.url.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new Error( + `Invalid MCP URL for ${cleanId}: must be http:// or https://`, + ); + } + mcpConfig.url = url; + } + + // Handle command-based MCPs + if (typeof cfg.command === "string") { + mcpConfig.command = cfg.command; + if (Array.isArray(cfg.args)) { + mcpConfig.args = cfg.args.filter((a) => typeof a === "string"); + } + } + + // Optional fields + if (typeof cfg.description === "string") { + mcpConfig.description = cfg.description; + } + if (typeof cfg.enabled === "boolean") { + mcpConfig.enabled = cfg.enabled; + } + + // Copy through any other config fields (oauth, headers, etc.) + for (const [key, value] of Object.entries(cfg)) { + if ( + !["url", "command", "args", "description", "enabled"].includes(key) + ) { + mcpConfig[key] = value; + } + } + + settings.mcpServers[cleanId] = mcpConfig; + } + } + + if (input.skillsConfig) { + settings.skillsConfig = input.skillsConfig; + } + + if (input.pluginsConfig) { + settings.pluginsConfig = { + plugins: input.pluginsConfig.plugins + .filter((p) => typeof p.source === "string" && p.source.trim()) + .map((p) => ({ + source: p.source.trim(), + slot: p.slot, + enabled: p.enabled ?? true, + config: + p.config && typeof p.config === "object" + ? { ...p.config } + : undefined, + })), + }; + } + + if (typeof input.verboseLogging === "boolean") { + settings.verboseLogging = input.verboseLogging; + } + + return settings; } /** @@ -1133,45 +1142,45 @@ async function validateSettings( * OAuth integrations don't need registration (resolved at auth time from platform config + skill scopes). */ async function autoRegisterSkillIntegrations( - agentSettingsStore: AgentSettingsStore, - agentId: string, - skills: SkillConfig[] + agentSettingsStore: AgentSettingsStore, + agentId: string, + skills: SkillConfig[], ): Promise { - const apiKeyIntegrations: Record = {}; - - for (const skill of skills) { - if (!skill.enabled || !skill.integrations) continue; - for (const raw of skill.integrations) { - const ig = normalizeSkillIntegration(raw); - if (ig.authType !== "api-key") continue; - - apiKeyIntegrations[ig.id] = { - label: ig.label || ig.id, - authType: "api-key", - apiKey: { - headerName: "Authorization", - headerTemplate: "Bearer {{key}}", - }, - apiDomains: ig.apiDomains || [], - }; - } - } - - if (Object.keys(apiKeyIntegrations).length === 0) return; - - const existing = await agentSettingsStore.getSettings(agentId); - const merged = { - ...(existing?.agentIntegrations || {}), - ...apiKeyIntegrations, - }; - await agentSettingsStore.updateSettings(agentId, { - agentIntegrations: merged, - }); - - logger.info("Auto-registered API-key integrations from skills", { - agentId, - integrations: Object.keys(apiKeyIntegrations), - }); + const apiKeyIntegrations: Record = {}; + + for (const skill of skills) { + if (!skill.enabled || !skill.integrations) continue; + for (const raw of skill.integrations) { + const ig = normalizeSkillIntegration(raw); + if (ig.authType !== "api-key") continue; + + apiKeyIntegrations[ig.id] = { + label: ig.label || ig.id, + authType: "api-key", + apiKey: { + headerName: "Authorization", + headerTemplate: "Bearer {{key}}", + }, + apiDomains: ig.apiDomains || [], + }; + } + } + + if (Object.keys(apiKeyIntegrations).length === 0) return; + + const existing = await agentSettingsStore.getSettings(agentId); + const merged = { + ...(existing?.agentIntegrations || {}), + ...apiKeyIntegrations, + }; + await agentSettingsStore.updateSettings(agentId, { + agentIntegrations: merged, + }); + + logger.info("Auto-registered API-key integrations from skills", { + agentId, + integrations: Object.keys(apiKeyIntegrations), + }); } /** @@ -1179,83 +1188,83 @@ async function autoRegisterSkillIntegrations( * Returns sets of IDs to remove. */ function computeSkillDependencyDiff( - oldSkills: SkillConfig[], - newSkills: SkillConfig[] + oldSkills: SkillConfig[], + newSkills: SkillConfig[], ): { - removedIntegrations: string[]; - removedMcpServers: string[]; - removedNixPackages: string[]; - removedPermissions: string[]; + removedIntegrations: string[]; + removedMcpServers: string[]; + removedNixPackages: string[]; + removedPermissions: string[]; } { - // Collect all dependencies from enabled new skills - const activeIntegrations = new Set(); - const activeMcpServers = new Set(); - const activeNixPackages = new Set(); - const activePermissions = new Set(); - - for (const skill of newSkills) { - if (!skill.enabled) continue; - if (skill.integrations) { - for (const ig of skill.integrations) { - const normalized = normalizeSkillIntegration(ig); - if (normalized.authType === "api-key") { - activeIntegrations.add(normalized.id); - } - } - } - if (skill.mcpServers) { - for (const mcp of skill.mcpServers) activeMcpServers.add(mcp.id); - } - if (skill.nixPackages) { - for (const pkg of skill.nixPackages) activeNixPackages.add(pkg); - } - if (skill.permissions) { - for (const perm of skill.permissions) activePermissions.add(perm); - } - } - - // Collect all dependencies from enabled old skills - const previousIntegrations = new Set(); - const previousMcpServers = new Set(); - const previousNixPackages = new Set(); - const previousPermissions = new Set(); - - for (const skill of oldSkills) { - if (!skill.enabled) continue; - if (skill.integrations) { - for (const ig of skill.integrations) { - const normalized = normalizeSkillIntegration(ig); - if (normalized.authType === "api-key") { - previousIntegrations.add(normalized.id); - } - } - } - if (skill.mcpServers) { - for (const mcp of skill.mcpServers) previousMcpServers.add(mcp.id); - } - if (skill.nixPackages) { - for (const pkg of skill.nixPackages) previousNixPackages.add(pkg); - } - if (skill.permissions) { - for (const perm of skill.permissions) previousPermissions.add(perm); - } - } - - // Things that were needed before but aren't anymore - return { - removedIntegrations: [...previousIntegrations].filter( - (id) => !activeIntegrations.has(id) - ), - removedMcpServers: [...previousMcpServers].filter( - (id) => !activeMcpServers.has(id) - ), - removedNixPackages: [...previousNixPackages].filter( - (pkg) => !activeNixPackages.has(pkg) - ), - removedPermissions: [...previousPermissions].filter( - (perm) => !activePermissions.has(perm) - ), - }; + // Collect all dependencies from enabled new skills + const activeIntegrations = new Set(); + const activeMcpServers = new Set(); + const activeNixPackages = new Set(); + const activePermissions = new Set(); + + for (const skill of newSkills) { + if (!skill.enabled) continue; + if (skill.integrations) { + for (const ig of skill.integrations) { + const normalized = normalizeSkillIntegration(ig); + if (normalized.authType === "api-key") { + activeIntegrations.add(normalized.id); + } + } + } + if (skill.mcpServers) { + for (const mcp of skill.mcpServers) activeMcpServers.add(mcp.id); + } + if (skill.nixPackages) { + for (const pkg of skill.nixPackages) activeNixPackages.add(pkg); + } + if (skill.permissions) { + for (const perm of skill.permissions) activePermissions.add(perm); + } + } + + // Collect all dependencies from enabled old skills + const previousIntegrations = new Set(); + const previousMcpServers = new Set(); + const previousNixPackages = new Set(); + const previousPermissions = new Set(); + + for (const skill of oldSkills) { + if (!skill.enabled) continue; + if (skill.integrations) { + for (const ig of skill.integrations) { + const normalized = normalizeSkillIntegration(ig); + if (normalized.authType === "api-key") { + previousIntegrations.add(normalized.id); + } + } + } + if (skill.mcpServers) { + for (const mcp of skill.mcpServers) previousMcpServers.add(mcp.id); + } + if (skill.nixPackages) { + for (const pkg of skill.nixPackages) previousNixPackages.add(pkg); + } + if (skill.permissions) { + for (const perm of skill.permissions) previousPermissions.add(perm); + } + } + + // Things that were needed before but aren't anymore + return { + removedIntegrations: [...previousIntegrations].filter( + (id) => !activeIntegrations.has(id), + ), + removedMcpServers: [...previousMcpServers].filter( + (id) => !activeMcpServers.has(id), + ), + removedNixPackages: [...previousNixPackages].filter( + (pkg) => !activeNixPackages.has(pkg), + ), + removedPermissions: [...previousPermissions].filter( + (perm) => !activePermissions.has(perm), + ), + }; } /** @@ -1263,156 +1272,156 @@ function computeSkillDependencyDiff( * Only removes dependencies that were exclusively owned by removed skills. */ async function cleanupOrphanedSkillDependencies( - agentSettingsStore: AgentSettingsStore, - agentId: string, - oldSkills: SkillConfig[], - newSkills: SkillConfig[] + agentSettingsStore: AgentSettingsStore, + agentId: string, + oldSkills: SkillConfig[], + newSkills: SkillConfig[], ): Promise { - const diff = computeSkillDependencyDiff(oldSkills, newSkills); - - const hasRemovals = - diff.removedIntegrations.length > 0 || - diff.removedMcpServers.length > 0 || - diff.removedNixPackages.length > 0; - - if (!hasRemovals) return; - - const settings = await agentSettingsStore.getSettings(agentId); - if (!settings) return; - - const updates: Record = {}; - - // Remove orphaned API-key integrations - if (diff.removedIntegrations.length > 0 && settings.agentIntegrations) { - const cleaned = { ...settings.agentIntegrations }; - for (const id of diff.removedIntegrations) { - delete cleaned[id]; - } - updates.agentIntegrations = cleaned; - } - - // Remove orphaned MCP servers - if (diff.removedMcpServers.length > 0 && settings.mcpServers) { - const cleaned = { ...settings.mcpServers }; - for (const id of diff.removedMcpServers) { - delete cleaned[id]; - } - updates.mcpServers = cleaned; - } - - // Remove orphaned nix packages - if (diff.removedNixPackages.length > 0 && settings.nixConfig?.packages) { - const removedSet = new Set(diff.removedNixPackages); - const remaining = settings.nixConfig.packages.filter( - (p) => !removedSet.has(p) - ); - updates.nixConfig = { - ...settings.nixConfig, - packages: remaining.length > 0 ? remaining : undefined, - }; - } - - if (Object.keys(updates).length > 0) { - await agentSettingsStore.updateSettings(agentId, updates); - logger.info("Cleaned up orphaned skill dependencies", { - agentId, - removedIntegrations: diff.removedIntegrations, - removedMcpServers: diff.removedMcpServers, - removedNixPackages: diff.removedNixPackages, - }); - } + const diff = computeSkillDependencyDiff(oldSkills, newSkills); + + const hasRemovals = + diff.removedIntegrations.length > 0 || + diff.removedMcpServers.length > 0 || + diff.removedNixPackages.length > 0; + + if (!hasRemovals) return; + + const settings = await agentSettingsStore.getSettings(agentId); + if (!settings) return; + + const updates: Record = {}; + + // Remove orphaned API-key integrations + if (diff.removedIntegrations.length > 0 && settings.agentIntegrations) { + const cleaned = { ...settings.agentIntegrations }; + for (const id of diff.removedIntegrations) { + delete cleaned[id]; + } + updates.agentIntegrations = cleaned; + } + + // Remove orphaned MCP servers + if (diff.removedMcpServers.length > 0 && settings.mcpServers) { + const cleaned = { ...settings.mcpServers }; + for (const id of diff.removedMcpServers) { + delete cleaned[id]; + } + updates.mcpServers = cleaned; + } + + // Remove orphaned nix packages + if (diff.removedNixPackages.length > 0 && settings.nixConfig?.packages) { + const removedSet = new Set(diff.removedNixPackages); + const remaining = settings.nixConfig.packages.filter( + (p) => !removedSet.has(p), + ); + updates.nixConfig = { + ...settings.nixConfig, + packages: remaining.length > 0 ? remaining : undefined, + }; + } + + if (Object.keys(updates).length > 0) { + await agentSettingsStore.updateSettings(agentId, updates); + logger.info("Cleaned up orphaned skill dependencies", { + agentId, + removedIntegrations: diff.removedIntegrations, + removedMcpServers: diff.removedMcpServers, + removedNixPackages: diff.removedNixPackages, + }); + } } function getEnabledHttpMcpIds( - mcpServers: AgentSettings["mcpServers"] | undefined + mcpServers: AgentSettings["mcpServers"] | undefined, ): Set { - const ids = new Set(); - for (const [id, config] of Object.entries(mcpServers || {})) { - if (!config || typeof config !== "object") continue; - const cfg = config as Record; - if (cfg.enabled === false) continue; - if (typeof cfg.url !== "string") continue; - const url = cfg.url.trim(); - if (!url.startsWith("http://") && !url.startsWith("https://")) continue; - ids.add(id); - } - return ids; + const ids = new Set(); + for (const [id, config] of Object.entries(mcpServers || {})) { + if (!config || typeof config !== "object") continue; + const cfg = config as Record; + if (cfg.enabled === false) continue; + if (typeof cfg.url !== "string") continue; + const url = cfg.url.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) continue; + ids.add(id); + } + return ids; } async function maybeSendMcpInstalledNotifications(options: { - queue: IMessageQueue; - agentSettingsStore: AgentSettingsStore; - agentId: string; - userId: string; - platform: string; - channelId: string; - conversationId: string; - teamId?: string; - previousSettings: AgentSettings | null; - nextMcpServers: AgentSettings["mcpServers"] | undefined; + queue: IMessageQueue; + agentSettingsStore: AgentSettingsStore; + agentId: string; + userId: string; + platform: string; + channelId: string; + conversationId: string; + teamId?: string; + previousSettings: AgentSettings | null; + nextMcpServers: AgentSettings["mcpServers"] | undefined; }): Promise { - const { - queue, - agentSettingsStore, - agentId, - userId, - platform, - channelId, - conversationId, - teamId, - previousSettings, - nextMcpServers, - } = options; - - const previousMcpIds = getEnabledHttpMcpIds(previousSettings?.mcpServers); - const previousNotified = { ...(previousSettings?.mcpInstallNotified || {}) }; - const currentMcpIds = getEnabledHttpMcpIds(nextMcpServers); - - const candidatesToNotify = Array.from(currentMcpIds).filter( - (mcpId) => !previousMcpIds.has(mcpId) && !previousNotified[mcpId] - ); - - if (candidatesToNotify.length === 0) return; - - await queue.createQueue("thread_response"); - - const notifiedUpdates: Record = { ...previousNotified }; - for (const mcpId of candidatesToNotify) { - const messageId = `mcp-installed:${agentId}:${mcpId}:${Date.now()}`; - try { - await queue.send("thread_response", { - messageId, - channelId, - conversationId, - userId, - teamId: teamId || "no-team", - platform, - content: `MCP "${mcpId}" is installed and ready. You can use it in this chat on your next message.`, - timestamp: Date.now(), - ephemeral: true, - }); - notifiedUpdates[mcpId] = Date.now(); - logger.info("Sent MCP installed notification", { - agentId, - mcpId, - channelId, - conversationId, - }); - } catch (error) { - logger.warn("Failed to send MCP installed notification", { - agentId, - mcpId, - error, - }); - } - } - - const changed = - Object.keys(notifiedUpdates).length !== - Object.keys(previousNotified).length; - if (changed) { - await agentSettingsStore.updateSettings(agentId, { - mcpInstallNotified: notifiedUpdates, - }); - } + const { + queue, + agentSettingsStore, + agentId, + userId, + platform, + channelId, + conversationId, + teamId, + previousSettings, + nextMcpServers, + } = options; + + const previousMcpIds = getEnabledHttpMcpIds(previousSettings?.mcpServers); + const previousNotified = { ...(previousSettings?.mcpInstallNotified || {}) }; + const currentMcpIds = getEnabledHttpMcpIds(nextMcpServers); + + const candidatesToNotify = Array.from(currentMcpIds).filter( + (mcpId) => !previousMcpIds.has(mcpId) && !previousNotified[mcpId], + ); + + if (candidatesToNotify.length === 0) return; + + await queue.createQueue("thread_response"); + + const notifiedUpdates: Record = { ...previousNotified }; + for (const mcpId of candidatesToNotify) { + const messageId = `mcp-installed:${agentId}:${mcpId}:${Date.now()}`; + try { + await queue.send("thread_response", { + messageId, + channelId, + conversationId, + userId, + teamId: teamId || "no-team", + platform, + content: `MCP "${mcpId}" is installed and ready. You can use it in this chat on your next message.`, + timestamp: Date.now(), + ephemeral: true, + }); + notifiedUpdates[mcpId] = Date.now(); + logger.info("Sent MCP installed notification", { + agentId, + mcpId, + channelId, + conversationId, + }); + } catch (error) { + logger.warn("Failed to send MCP installed notification", { + agentId, + mcpId, + error, + }); + } + } + + const changed = + Object.keys(notifiedUpdates).length !== + Object.keys(previousNotified).length; + if (changed) { + await agentSettingsStore.updateSettings(agentId, { + mcpInstallNotified: notifiedUpdates, + }); + } } diff --git a/packages/gateway/src/routes/public/agent-schedules.ts b/packages/gateway/src/routes/public/agent-schedules.ts index 71a52ad0e..9744a8027 100644 --- a/packages/gateway/src/routes/public/agent-schedules.ts +++ b/packages/gateway/src/routes/public/agent-schedules.ts @@ -6,7 +6,7 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; -import type { SettingsTokenPayload } from "../../auth/settings/token-service"; +import type { SettingsSessionPayload } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ScheduledWakeupService } from "../../orchestration/scheduled-wakeup"; import { verifySettingsSession } from "./settings-auth"; @@ -18,155 +18,155 @@ const TokenQuery = z.object({ token: z.string().optional() }); // --- Route Definitions --- const listSchedulesRoute = createRoute({ - method: "get", - path: "/", - tags: [TAG], - summary: "List agent schedules", - request: { query: TokenQuery }, - responses: { - 200: { - description: "Schedules", - content: { - "application/json": { - schema: z.object({ - schedules: z.array( - z.object({ - scheduleId: z.string(), - conversationId: z.string(), - task: z.string(), - scheduledAt: z.number(), - scheduledFor: z.number(), - status: z.string(), - }) - ), - }), - }, - }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, + method: "get", + path: "/", + tags: [TAG], + summary: "List agent schedules", + request: { query: TokenQuery }, + responses: { + 200: { + description: "Schedules", + content: { + "application/json": { + schema: z.object({ + schedules: z.array( + z.object({ + scheduleId: z.string(), + conversationId: z.string(), + task: z.string(), + scheduledAt: z.number(), + scheduledFor: z.number(), + status: z.string(), + }), + ), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponse } }, + }, + }, }); const cancelScheduleRoute = createRoute({ - method: "delete", - path: "/{scheduleId}", - tags: [TAG], - summary: "Cancel agent schedule", - request: { - query: TokenQuery, - params: z.object({ scheduleId: z.string() }), - }, - responses: { - 200: { - description: "Cancelled", - content: { - "application/json": { - schema: z.object({ - success: z.boolean(), - message: z.string().optional(), - }), - }, - }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, + method: "delete", + path: "/{scheduleId}", + tags: [TAG], + summary: "Cancel agent schedule", + request: { + query: TokenQuery, + params: z.object({ scheduleId: z.string() }), + }, + responses: { + 200: { + description: "Cancelled", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + message: z.string().optional(), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponse } }, + }, + }, }); export interface AgentSchedulesRoutesConfig { - scheduledWakeupService?: ScheduledWakeupService; - userAgentsStore?: UserAgentsStore; - agentMetadataStore?: AgentMetadataStore; + scheduledWakeupService?: ScheduledWakeupService; + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: AgentMetadataStore; } export function createAgentSchedulesRoutes( - config: AgentSchedulesRoutesConfig + config: AgentSchedulesRoutesConfig, ): OpenAPIHono { - const app = new OpenAPIHono(); - - const verifyToken = async ( - payload: SettingsTokenPayload | null, - agentId: string - ): Promise => { - if (!payload) return null; - - if (payload.agentId) { - if (payload.agentId !== agentId) return null; - } else { - const owns = config.userAgentsStore - ? await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ) - : false; - - if (!owns) { - if (!config.agentMetadataStore) return null; - const metadata = await config.agentMetadataStore.getMetadata(agentId); - const isOwner = - metadata?.owner?.platform === payload.platform && - metadata?.owner?.userId === payload.userId; - if (!isOwner && !metadata?.isWorkspaceAgent) return null; - - if (isOwner && config.userAgentsStore) { - config.userAgentsStore - .addAgent(payload.platform, payload.userId, agentId) - .catch(() => { - /* best-effort reconciliation */ - }); - } - } - } - return payload; - }; - - app.openapi(listSchedulesRoute, async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - if (!config.scheduledWakeupService) return c.json({ schedules: [] }); - - const schedules = - await config.scheduledWakeupService.listPendingForAgent(agentId); - return c.json({ - schedules: schedules.map((s) => ({ - scheduleId: s.id, - conversationId: s.conversationId, - task: s.task, - scheduledAt: s.scheduledAt, - scheduledFor: s.triggerAt, - status: s.status, - })), - }); - }); - - app.openapi(cancelScheduleRoute, async (c): Promise => { - const agentId = c.req.param("agentId") || ""; - const payload = await verifyToken(await verifySettingsSession(c), agentId); - if (!payload) return c.json({ error: "Unauthorized" }, 401); - - if (!config.scheduledWakeupService) { - return c.json({ error: "Not configured" }, 500); - } - - const { scheduleId } = c.req.valid("param"); - const success = await config.scheduledWakeupService.cancelByAgent( - scheduleId, - agentId - ); - - return c.json({ - success, - message: success ? undefined : "Not found or already triggered", - }); - }); - - return app; + const app = new OpenAPIHono(); + + const verifyToken = async ( + payload: SettingsSessionPayload | null, + agentId: string, + ): Promise => { + if (!payload) return null; + + if (payload.agentId) { + if (payload.agentId !== agentId) return null; + } else { + const owns = config.userAgentsStore + ? await config.userAgentsStore.ownsAgent( + payload.platform, + payload.userId, + agentId, + ) + : false; + + if (!owns) { + if (!config.agentMetadataStore) return null; + const metadata = await config.agentMetadataStore.getMetadata(agentId); + const isOwner = + metadata?.owner?.platform === payload.platform && + metadata?.owner?.userId === payload.userId; + if (!isOwner && !metadata?.isWorkspaceAgent) return null; + + if (isOwner && config.userAgentsStore) { + config.userAgentsStore + .addAgent(payload.platform, payload.userId, agentId) + .catch(() => { + /* best-effort reconciliation */ + }); + } + } + } + return payload; + }; + + app.openapi(listSchedulesRoute, async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + if (!config.scheduledWakeupService) return c.json({ schedules: [] }); + + const schedules = + await config.scheduledWakeupService.listPendingForAgent(agentId); + return c.json({ + schedules: schedules.map((s) => ({ + scheduleId: s.id, + conversationId: s.conversationId, + task: s.task, + scheduledAt: s.scheduledAt, + scheduledFor: s.triggerAt, + status: s.status, + })), + }); + }); + + app.openapi(cancelScheduleRoute, async (c): Promise => { + const agentId = c.req.param("agentId") || ""; + const payload = await verifyToken(await verifySettingsSession(c), agentId); + if (!payload) return c.json({ error: "Unauthorized" }, 401); + + if (!config.scheduledWakeupService) { + return c.json({ error: "Not configured" }, 500); + } + + const { scheduleId } = c.req.valid("param"); + const success = await config.scheduledWakeupService.cancelByAgent( + scheduleId, + agentId, + ); + + return c.json({ + success, + message: success ? undefined : "Not found or already triggered", + }); + }); + + return app; } diff --git a/packages/gateway/src/routes/public/channels.ts b/packages/gateway/src/routes/public/channels.ts index 466bcbf03..0d734c4c8 100644 --- a/packages/gateway/src/routes/public/channels.ts +++ b/packages/gateway/src/routes/public/channels.ts @@ -10,19 +10,17 @@ import { createLogger } from "@lobu/core"; import { Hono } from "hono"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; -import { - type SettingsTokenPayload, - verifySettingsToken, -} from "../../auth/settings/token-service"; +import type { SettingsSessionPayload } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChannelBindingService } from "../../channels"; +import { verifySettingsSession } from "./settings-auth"; const logger = createLogger("channel-binding-routes"); export interface ChannelBindingRoutesConfig { - channelBindingService: ChannelBindingService; - userAgentsStore?: UserAgentsStore; - agentMetadataStore?: AgentMetadataStore; + channelBindingService: ChannelBindingService; + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: AgentMetadataStore; } /** @@ -30,220 +28,219 @@ export interface ChannelBindingRoutesConfig { * These are mounted under /api/v1/agents/{agentId}/channels */ export function createChannelBindingRoutes( - config: ChannelBindingRoutesConfig + config: ChannelBindingRoutesConfig, ): Hono { - const router = new Hono(); - - const verifyToken = async ( - token: string | undefined, - agentId: string - ): Promise => { - if (!token) return null; - const payload = verifySettingsToken(token); - if (!payload) return null; - - if (payload.agentId) { - if (payload.agentId !== agentId) return null; - } else { - const owns = config.userAgentsStore - ? await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ) - : false; - - if (!owns) { - if (!config.agentMetadataStore) return null; - const metadata = await config.agentMetadataStore.getMetadata(agentId); - const isOwner = - metadata?.owner?.platform === payload.platform && - metadata?.owner?.userId === payload.userId; - if (!isOwner && !metadata?.isWorkspaceAgent) return null; - - if (isOwner && config.userAgentsStore) { - config.userAgentsStore - .addAgent(payload.platform, payload.userId, agentId) - .catch(() => { - /* best-effort reconciliation */ - }); - } - } - } - - return payload; - }; - - // GET /api/v1/agents/{agentId}/channels - List all bindings for an agent - router.get("/", async (c) => { - const agentId = c.req.param("agentId"); - - if (!agentId) { - return c.json({ error: "Missing agentId" }, 400); - } - - if (!(await verifyToken(c.req.query("token"), agentId))) { - return c.json({ error: "Unauthorized" }, 401); - } - - try { - const bindings = await config.channelBindingService.listBindings(agentId); - - return c.json({ - agentId, - bindings: bindings.map((b) => ({ - platform: b.platform, - channelId: b.channelId, - teamId: b.teamId, - createdAt: b.createdAt, - })), - }); - } catch (error) { - logger.error("Failed to list bindings", { error, agentId }); - return c.json({ error: "Failed to list bindings" }, 500); - } - }); - - // POST /api/v1/agents/{agentId}/channels - Create a new binding - router.post("/", async (c) => { - const agentId = c.req.param("agentId"); - - if (!agentId) { - return c.json({ error: "Missing agentId" }, 400); - } - - const authPayload = await verifyToken(c.req.query("token"), agentId); - if (!authPayload) { - return c.json({ error: "Unauthorized" }, 401); - } - - try { - const body = await c.req.json<{ - platform: string; - channelId: string; - teamId?: string; - }>(); - - // Validate required fields - if (!body.platform || !body.channelId) { - return c.json( - { error: "Missing required fields: platform, channelId" }, - 400 - ); - } - - // Validate platform format (alphanumeric, lowercase) - if (!/^[a-z][a-z0-9_-]*$/.test(body.platform)) { - return c.json( - { error: "Invalid platform format. Must be lowercase alphanumeric." }, - 400 - ); - } - - // Validate channelId format - if (typeof body.channelId !== "string" || !body.channelId.trim()) { - return c.json({ error: "Invalid channelId" }, 400); - } - - // Validate optional teamId - if ( - body.teamId && - (typeof body.teamId !== "string" || !body.teamId.trim()) - ) { - return c.json({ error: "Invalid teamId" }, 400); - } - - await config.channelBindingService.createBinding( - agentId, - body.platform, - body.channelId.trim(), - body.teamId?.trim(), - { configuredBy: authPayload.userId } - ); - - logger.info( - `Created binding: ${body.platform}/${body.channelId} -> ${agentId}` - ); - - return c.json({ - success: true, - agentId, - platform: body.platform, - channelId: body.channelId, - teamId: body.teamId, - }); - } catch (error) { - logger.error("Failed to create binding", { error, agentId }); - return c.json( - { - error: - error instanceof Error ? error.message : "Failed to create binding", - }, - 400 - ); - } - }); - - // DELETE /api/v1/agents/{agentId}/channels/{platform}/{channelId} - Delete a binding - router.delete("/:platform/:channelId", async (c) => { - const agentId = c.req.param("agentId"); - const platform = c.req.param("platform"); - const channelId = c.req.param("channelId"); - const teamId = c.req.query("teamId"); // Optional query param for multi-tenant platforms - - if (!agentId || !platform || !channelId) { - return c.json({ error: "Missing required parameters" }, 400); - } - - if (!(await verifyToken(c.req.query("token"), agentId))) { - return c.json({ error: "Unauthorized" }, 401); - } - - // Validate platform format - if (!/^[a-z][a-z0-9_-]*$/.test(platform)) { - return c.json({ error: "Invalid platform format" }, 400); - } - - try { - const deleted = await config.channelBindingService.deleteBinding( - agentId, - platform, - channelId, - teamId || undefined - ); - - if (!deleted) { - return c.json( - { error: "Binding not found or belongs to a different agent" }, - 404 - ); - } - - logger.info(`Deleted binding: ${platform}/${channelId} from ${agentId}`); - - return c.json({ - success: true, - agentId, - platform, - channelId, - }); - } catch (error) { - logger.error("Failed to delete binding", { - error, - agentId, - platform, - channelId, - }); - return c.json( - { - error: - error instanceof Error ? error.message : "Failed to delete binding", - }, - 400 - ); - } - }); - - logger.info("Channel binding routes registered"); - return router; + const router = new Hono(); + + const verifySession = async ( + c: import("hono").Context, + agentId: string, + ): Promise => { + const payload = await verifySettingsSession(c); + if (!payload) return null; + + if (payload.agentId) { + if (payload.agentId !== agentId) return null; + } else { + const owns = config.userAgentsStore + ? await config.userAgentsStore.ownsAgent( + payload.platform, + payload.userId, + agentId, + ) + : false; + + if (!owns) { + if (!config.agentMetadataStore) return null; + const metadata = await config.agentMetadataStore.getMetadata(agentId); + const isOwner = + metadata?.owner?.platform === payload.platform && + metadata?.owner?.userId === payload.userId; + if (!isOwner && !metadata?.isWorkspaceAgent) return null; + + if (isOwner && config.userAgentsStore) { + config.userAgentsStore + .addAgent(payload.platform, payload.userId, agentId) + .catch(() => { + /* best-effort reconciliation */ + }); + } + } + } + + return payload; + }; + + // GET /api/v1/agents/{agentId}/channels - List all bindings for an agent + router.get("/", async (c) => { + const agentId = c.req.param("agentId"); + + if (!agentId) { + return c.json({ error: "Missing agentId" }, 400); + } + + if (!(await verifySession(c, agentId))) { + return c.json({ error: "Unauthorized" }, 401); + } + + try { + const bindings = await config.channelBindingService.listBindings(agentId); + + return c.json({ + agentId, + bindings: bindings.map((b) => ({ + platform: b.platform, + channelId: b.channelId, + teamId: b.teamId, + createdAt: b.createdAt, + })), + }); + } catch (error) { + logger.error("Failed to list bindings", { error, agentId }); + return c.json({ error: "Failed to list bindings" }, 500); + } + }); + + // POST /api/v1/agents/{agentId}/channels - Create a new binding + router.post("/", async (c) => { + const agentId = c.req.param("agentId"); + + if (!agentId) { + return c.json({ error: "Missing agentId" }, 400); + } + + const authPayload = await verifySession(c, agentId); + if (!authPayload) { + return c.json({ error: "Unauthorized" }, 401); + } + + try { + const body = await c.req.json<{ + platform: string; + channelId: string; + teamId?: string; + }>(); + + // Validate required fields + if (!body.platform || !body.channelId) { + return c.json( + { error: "Missing required fields: platform, channelId" }, + 400, + ); + } + + // Validate platform format (alphanumeric, lowercase) + if (!/^[a-z][a-z0-9_-]*$/.test(body.platform)) { + return c.json( + { error: "Invalid platform format. Must be lowercase alphanumeric." }, + 400, + ); + } + + // Validate channelId format + if (typeof body.channelId !== "string" || !body.channelId.trim()) { + return c.json({ error: "Invalid channelId" }, 400); + } + + // Validate optional teamId + if ( + body.teamId && + (typeof body.teamId !== "string" || !body.teamId.trim()) + ) { + return c.json({ error: "Invalid teamId" }, 400); + } + + await config.channelBindingService.createBinding( + agentId, + body.platform, + body.channelId.trim(), + body.teamId?.trim(), + { configuredBy: authPayload.userId }, + ); + + logger.info( + `Created binding: ${body.platform}/${body.channelId} -> ${agentId}`, + ); + + return c.json({ + success: true, + agentId, + platform: body.platform, + channelId: body.channelId, + teamId: body.teamId, + }); + } catch (error) { + logger.error("Failed to create binding", { error, agentId }); + return c.json( + { + error: + error instanceof Error ? error.message : "Failed to create binding", + }, + 400, + ); + } + }); + + // DELETE /api/v1/agents/{agentId}/channels/{platform}/{channelId} - Delete a binding + router.delete("/:platform/:channelId", async (c) => { + const agentId = c.req.param("agentId"); + const platform = c.req.param("platform"); + const channelId = c.req.param("channelId"); + const teamId = c.req.query("teamId"); // Optional query param for multi-tenant platforms + + if (!agentId || !platform || !channelId) { + return c.json({ error: "Missing required parameters" }, 400); + } + + if (!(await verifySession(c, agentId))) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Validate platform format + if (!/^[a-z][a-z0-9_-]*$/.test(platform)) { + return c.json({ error: "Invalid platform format" }, 400); + } + + try { + const deleted = await config.channelBindingService.deleteBinding( + agentId, + platform, + channelId, + teamId || undefined, + ); + + if (!deleted) { + return c.json( + { error: "Binding not found or belongs to a different agent" }, + 404, + ); + } + + logger.info(`Deleted binding: ${platform}/${channelId} from ${agentId}`); + + return c.json({ + success: true, + agentId, + platform, + channelId, + }); + } catch (error) { + logger.error("Failed to delete binding", { + error, + agentId, + platform, + channelId, + }); + return c.json( + { + error: + error instanceof Error ? error.message : "Failed to delete binding", + }, + 400, + ); + } + }); + + logger.info("Channel binding routes registered"); + return router; } diff --git a/packages/gateway/src/routes/public/history-page/index.ts b/packages/gateway/src/routes/public/history-page/index.ts index 0875c9b5f..7334da6c0 100644 --- a/packages/gateway/src/routes/public/history-page/index.ts +++ b/packages/gateway/src/routes/public/history-page/index.ts @@ -6,16 +6,16 @@ import { settingsPageCSS } from "../settings-page-styles"; let historyPageJS = ""; try { - // Dynamic import to handle case where bundle hasn't been generated yet - const bundle = require("../history-page-bundle"); - historyPageJS = bundle.historyPageJS; + // Dynamic import to handle case where bundle hasn't been generated yet + const bundle = require("../history-page-bundle"); + historyPageJS = bundle.historyPageJS; } catch { - historyPageJS = - 'document.getElementById("app").textContent = "Bundle not built. Run: bun run scripts/build-history.ts";'; + historyPageJS = + 'document.getElementById("app").textContent = "Bundle not built. Run: bun run scripts/build-history.ts";'; } export function renderHistoryPage(agentId: string): string { - return ` + return ` @@ -30,20 +30,19 @@ export function renderHistoryPage(agentId: string): string {
diff --git a/packages/gateway/src/routes/public/settings.ts b/packages/gateway/src/routes/public/settings.ts index 53d1310d3..9781aa306 100644 --- a/packages/gateway/src/routes/public/settings.ts +++ b/packages/gateway/src/routes/public/settings.ts @@ -15,8 +15,8 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { createLogger } from "@lobu/core"; import type { - AgentMetadata, - AgentMetadataStore, + AgentMetadata, + AgentMetadataStore, } from "../../auth/agent-metadata-store"; import { collectProviderModelOptions } from "../../auth/provider-model-options"; import type { AgentSettingsStore } from "../../auth/settings"; @@ -28,524 +28,531 @@ import type { ChannelBindingService } from "../../channels"; import { getModelProviderModules } from "../../modules/module-system"; import { verifyTelegramWebAppData } from "../../telegram/webapp-auth"; import { - clearSettingsSessionCookie, - setSettingsSessionCookie, - verifySettingsSession, + clearSettingsSessionCookie, + setSettingsSessionCookie, + verifySettingsSession, } from "./settings-auth"; import type { ProviderMeta } from "./settings-page"; import { - renderErrorPage, - renderPickerPage, - renderSessionBootstrapPage, - renderSettingsPage, + renderErrorPage, + renderPickerPage, + renderSettingsPage, + renderTelegramBootstrapPage, } from "./settings-page"; const logger = createLogger("settings-routes"); export interface SettingsPageConfig { - agentSettingsStore: AgentSettingsStore; - userAgentsStore: UserAgentsStore; - agentMetadataStore: AgentMetadataStore; - channelBindingService: ChannelBindingService; - sessionStore: AuthSessionStore; - oauthProvider?: SettingsOAuthProvider; - identityStore?: OAuthIdentityStore; - integrationConfigService?: import("../../auth/integration/config-service").IntegrationConfigService; - integrationCredentialStore?: import("../../auth/integration/credential-store").IntegrationCredentialStore; - connectionManager?: import("../../gateway/connection-manager").WorkerConnectionManager; - systemSkillsService?: import("../../services/system-skills-service").SystemSkillsService; + agentSettingsStore: AgentSettingsStore; + userAgentsStore: UserAgentsStore; + agentMetadataStore: AgentMetadataStore; + channelBindingService: ChannelBindingService; + sessionStore: AuthSessionStore; + oauthProvider?: SettingsOAuthProvider; + identityStore?: OAuthIdentityStore; + integrationConfigService?: import("../../auth/integration/config-service").IntegrationConfigService; + integrationCredentialStore?: import("../../auth/integration/credential-store").IntegrationCredentialStore; + connectionManager?: import("../../gateway/connection-manager").WorkerConnectionManager; + systemSkillsService?: import("../../services/system-skills-service").SystemSkillsService; } function buildProviderMeta( - m: ReturnType[number] + m: ReturnType[number], ): ProviderMeta { - return { - id: m.providerId, - name: m.providerDisplayName, - iconUrl: m.providerIconUrl || "", - authType: (m.authType || "oauth") as ProviderMeta["authType"], - supportedAuthTypes: - (m.supportedAuthTypes as ProviderMeta["supportedAuthTypes"]) || [ - m.authType || "oauth", - ], - apiKeyInstructions: m.apiKeyInstructions || "", - apiKeyPlaceholder: m.apiKeyPlaceholder || "", - catalogDescription: m.catalogDescription || "", - }; + return { + id: m.providerId, + name: m.providerDisplayName, + iconUrl: m.providerIconUrl || "", + authType: (m.authType || "oauth") as ProviderMeta["authType"], + supportedAuthTypes: + (m.supportedAuthTypes as ProviderMeta["supportedAuthTypes"]) || [ + m.authType || "oauth", + ], + apiKeyInstructions: m.apiKeyInstructions || "", + apiKeyPlaceholder: m.apiKeyPlaceholder || "", + catalogDescription: m.catalogDescription || "", + }; } export function createSettingsPageRoutes( - config: SettingsPageConfig + config: SettingsPageConfig, ): OpenAPIHono { - const app = new OpenAPIHono(); - - // ========================================================================= - // POST /settings/session — establish a session cookie - // ========================================================================= - app.post("/settings/session", async (c) => { - const body = await c.req - .json<{ - sessionId?: string; - token?: string; - initData?: string; - chatId?: string; - }>() - .catch( - (): { - sessionId?: string; - token?: string; - initData?: string; - chatId?: string; - } => ({}) - ); - - // Path A: Telegram WebApp initData authentication - if (body.initData) { - const botToken = process.env.TELEGRAM_BOT_TOKEN; - if (!botToken) { - return c.json({ error: "Telegram not configured" }, 500); - } - - const chatId = (body.chatId ?? "").trim(); - if (!chatId) { - return c.json({ error: "Missing chatId" }, 400); - } - - const webAppData = verifyTelegramWebAppData(body.initData, botToken); - if (!webAppData) { - clearSettingsSessionCookie(c); - return c.json({ error: "Invalid or expired Telegram data" }, 401); - } - - const userId = String(webAppData.user.id); - - // DM validation: chatId must equal userId - const chatIdNum = Number(chatId); - if (chatIdNum > 0 && chatId !== userId) { - return c.json({ error: "Chat ID mismatch" }, 403); - } - - // Create a server-side session (replaces the old encrypt-to-cookie approach) - const sessionTtlMs = 60 * 60 * 1000; - const { sessionId } = await config.sessionStore.createSession( - { - userId, - platform: "telegram", - channelId: chatId, - }, - sessionTtlMs - ); - - const payload = await config.sessionStore.getSession(sessionId); - if (!payload) { - clearSettingsSessionCookie(c); - return c.json({ error: "Failed to create session" }, 500); - } - - setSettingsSessionCookie(c, sessionId, payload); - return c.json({ success: true }); - } - - // Path B: Session-based authentication (new — opaque session ID) - const sessionId = (body.sessionId ?? body.token ?? "").trim(); - if (!sessionId) return c.json({ error: "Missing session ID" }, 400); - - const payload = await config.sessionStore.getSession(sessionId); - if (!payload) { - clearSettingsSessionCookie(c); - return c.json({ error: "Invalid or expired session" }, 401); - } - - // If OAuth provider is configured, redirect to OAuth instead of setting cookie directly - if (config.oauthProvider) { - const authUrl = await config.oauthProvider.startAuth( - payload.userId, - sessionId, - payload.platform - ); - return c.json({ oauthRedirect: authUrl }); - } - - setSettingsSessionCookie(c, sessionId, payload); - return c.json({ success: true }); - }); - - // ========================================================================= - // GET /settings/oauth/callback — OAuth identity verification callback - // ========================================================================= - if (config.oauthProvider && config.identityStore) { - const oauthProvider = config.oauthProvider; - const identityStore = config.identityStore; - - app.get("/settings/oauth/callback", async (c) => { - const code = c.req.query("code"); - const state = c.req.query("state"); - const error = c.req.query("error"); - - if (error) { - logger.warn("Settings OAuth error", { - error, - description: c.req.query("error_description"), - }); - return c.html( - renderErrorPage( - `Authentication failed: ${error}. Please request a new settings link.` - ), - 401 - ); - } - - if (!code || !state) { - return c.html( - renderErrorPage("Invalid OAuth callback (missing code or state)."), - 400 - ); - } - - try { - const result = await oauthProvider.handleCallback(code, state); - if (!result) { - return c.html( - renderErrorPage( - "Authentication failed. The link may have expired — request a new one." - ), - 401 - ); - } - - const { stateData, userInfo } = result; - - // Verify/establish identity mapping - const { linked, existingUserId } = await identityStore.linkIdentity( - oauthProvider.providerName, - userInfo.sub, - stateData.userId, - stateData.platform - ); - - if (!linked) { - logger.warn("OAuth identity mismatch", { - oauthSub: userInfo.sub, - sessionUserId: stateData.userId, - existingUserId, - }); - return c.html( - renderErrorPage( - "This OAuth account is already linked to a different user." - ), - 403 - ); - } - - // Load the session and set the cookie - const payload = await config.sessionStore.getSession( - stateData.sessionId - ); - if (!payload) { - return c.html( - renderErrorPage("Session expired. Please request a new link."), - 401 - ); - } - - setSettingsSessionCookie(c, stateData.sessionId, payload); - return c.redirect("/settings", 303); - } catch (err) { - logger.error("Settings OAuth callback failed", { error: err }); - return c.html( - renderErrorPage( - "Authentication failed due to a server error. Please try again." - ), - 500 - ); - } - }); - } - - // ========================================================================= - // GET /settings — HTML Settings Page - // ========================================================================= - app.get("/settings", async (c) => { - c.header("Referrer-Policy", "no-referrer"); - c.header("Cache-Control", "no-store, max-age=0"); - c.header("Pragma", "no-cache"); - - // Handle ?s= query param: validate session, set cookie, redirect clean - const querySessionId = c.req.query("s"); - if (querySessionId) { - const payload = await config.sessionStore.getSession(querySessionId); - if (!payload) { - clearSettingsSessionCookie(c); - return c.html( - renderErrorPage( - "Invalid or expired link. Use /configure to request a new settings link." - ), - 401 - ); - } - - // If OAuth configured, redirect through OAuth first - if (config.oauthProvider) { - const authUrl = await config.oauthProvider.startAuth( - payload.userId, - querySessionId, - payload.platform - ); - return c.redirect(authUrl, 303); - } - - setSettingsSessionCookie(c, querySessionId, payload); - return c.redirect("/settings", 303); - } - - const payload = await verifySettingsSession(c); - if (!payload) { - return c.html(renderSessionBootstrapPage()); - } - - // Determine the agentId to show settings for - let agentId = payload.agentId; - - if (!agentId && payload.channelId) { - // Channel-based entry: try to resolve via existing binding - const binding = await config.channelBindingService.getBinding( - payload.platform, - payload.channelId, - payload.teamId - ); - if (binding) { - agentId = binding.agentId; - } - } - - if (!agentId) { - // No agent resolved: show agent picker / creation form - const agentIds = await config.userAgentsStore.listAgents( - payload.platform, - payload.userId - ); - - const agents: (AgentMetadata & { channelCount: number })[] = []; - for (const id of agentIds) { - const metadata = await config.agentMetadataStore.getMetadata(id); - if (metadata) { - const bindings = await config.channelBindingService.listBindings(id); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - - return c.html(renderPickerPage(payload, agents)); - } - - // We have an agentId: render settings page - const [settings, agentMetadata] = await Promise.all([ - config.agentSettingsStore.getSettings(agentId), - config.agentMetadataStore.getMetadata(agentId), - ]); - - // Build provider metadata from registry - const allModules = getModelProviderModules(); - const allProviderMeta = allModules - .filter((m) => m.catalogVisible !== false) - .map(buildProviderMeta); - - // Resolve installed providers in order - const installedIds = (settings?.installedProviders || []).map( - (ip) => ip.providerId - ); - const installedSet = new Set(installedIds); - const installedProviders = installedIds - .map((id) => allProviderMeta.find((p) => p.id === id)) - .filter((p): p is ProviderMeta => p !== undefined); - - // Catalog providers = all that are not installed - const catalogProviders = allProviderMeta.filter( - (p) => !installedSet.has(p.id) - ); - - const providerModelOptions = await collectProviderModelOptions( - agentId, - payload.userId - ); - - // Determine if agent switcher should be shown - const showSwitcher = !!payload.channelId; - - // Get agents list for switcher (only if switcher is enabled) - const agents: (AgentMetadata & { channelCount: number })[] = []; - if (showSwitcher) { - const agentIds = await config.userAgentsStore.listAgents( - payload.platform, - payload.userId - ); - for (const id of agentIds) { - const metadata = await config.agentMetadataStore.getMetadata(id); - if (metadata) { - const bindings = await config.channelBindingService.listBindings(id); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - - // Ensure the currently active agent appears in switcher even when it is - // not part of the user's direct agent list (e.g. workspace-bound agent). - if ( - agentMetadata && - !agents.some((agent) => agent.agentId === agentMetadata.agentId) - ) { - const bindings = await config.channelBindingService.listBindings( - agentMetadata.agentId - ); - agents.unshift({ ...agentMetadata, channelCount: bindings.length }); - } - } - - // Load system skills to prepend to initial skills - let systemSkills: import("@lobu/core").SkillConfig[] = []; - if (config.systemSkillsService) { - try { - systemSkills = await config.systemSkillsService.getSystemSkills(); - } catch { - // System skills service may fail, continue without them - } - } - - // Fetch integration status keyed by integration ID - const integrationStatus: Record< - string, - { - connected: boolean; - accounts: { accountId: string; grantedScopes: string[] }[]; - availableScopes: string[]; - } - > = {}; - if (config.integrationConfigService && config.integrationCredentialStore) { - try { - const allConfigs = await config.integrationConfigService.getAll(); - for (const [id, cfg] of Object.entries(allConfigs)) { - const accountList = - await config.integrationCredentialStore.listAccounts(agentId, id); - integrationStatus[id] = { - connected: accountList.length > 0, - accounts: accountList.map((a) => ({ - accountId: a.accountId, - grantedScopes: a.credentials.grantedScopes, - })), - availableScopes: cfg.scopes?.available ?? [], - }; - } - } catch { - // Integration services may not be configured - } - } - - // Ensure the payload has agentId for the template (may have been resolved from binding) - const effectivePayload = { ...payload, agentId }; - - return c.html( - renderSettingsPage(effectivePayload, settings, { - providers: installedProviders, - catalogProviders, - providerModelOptions, - showSwitcher, - agents, - agentName: agentMetadata?.name, - agentDescription: agentMetadata?.description, - hasChannelId: !!payload.channelId, - systemSkills, - integrationStatus, - }) - ); - }); - - // Disconnect an OAuth integration account - app.post("/api/v1/integrations/oauth/disconnect", async (c) => { - const session = await verifySettingsSession(c); - if (!session) return c.json({ error: "Not authenticated" }, 401); - - const { agentId, integrationId, accountId } = await c.req.json<{ - agentId: string; - integrationId: string; - accountId?: string; - }>(); - - if (!agentId || !integrationId) { - return c.json({ error: "Missing agentId or integrationId" }, 400); - } - - if (!config.integrationCredentialStore) { - return c.json({ error: "Integration services not configured" }, 500); - } - - await config.integrationCredentialStore.deleteCredentials( - agentId, - integrationId, - accountId || "default" - ); - - // Notify active workers so they get updated integration status - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes: [`integration:${integrationId}:disconnected`], - }); - - return c.json({ success: true }); - }); - - // Save an API key for an api-key integration - app.post("/api/v1/integrations/apikey/save", async (c) => { - const session = await verifySettingsSession(c); - if (!session) return c.json({ error: "Not authenticated" }, 401); - - const { agentId, integrationId, apiKey } = await c.req.json<{ - agentId: string; - integrationId: string; - apiKey: string; - }>(); - - if (!agentId || !integrationId || !apiKey) { - return c.json( - { error: "Missing agentId, integrationId, or apiKey" }, - 400 - ); - } - - if ( - !config.integrationConfigService || - !config.integrationCredentialStore - ) { - return c.json({ error: "Integration services not configured" }, 500); - } - - // Verify the integration exists and is api-key type - const integrationConfig = - await config.integrationConfigService.getIntegration( - integrationId, - agentId - ); - if (!integrationConfig) { - return c.json({ error: "Integration not found" }, 404); - } - if ((integrationConfig.authType || "oauth") !== "api-key") { - return c.json({ error: "Integration is not an API key type" }, 400); - } - - // Store the API key as a credential - await config.integrationCredentialStore.setCredentials( - agentId, - integrationId, - { - accessToken: apiKey, - tokenType: "api-key", - grantedScopes: [], - } - ); - - // Notify active workers - config.connectionManager?.notifyAgent(agentId, "config_changed", { - changes: [`integration:${integrationId}:default:connected`], - }); - - return c.json({ success: true }); - }); - - return app; + const app = new OpenAPIHono(); + + // ========================================================================= + // POST /settings/session — establish a session cookie + // ========================================================================= + app.post("/settings/session", async (c) => { + const body = await c.req + .json<{ + sessionId?: string; + initData?: string; + chatId?: string; + }>() + .catch( + (): { sessionId?: string; initData?: string; chatId?: string } => ({}), + ); + + // Path A: Telegram WebApp initData authentication + if (body.initData) { + const botToken = process.env.TELEGRAM_BOT_TOKEN; + if (!botToken) { + return c.json({ error: "Telegram not configured" }, 500); + } + + const chatId = (body.chatId ?? "").trim(); + if (!chatId) { + return c.json({ error: "Missing chatId" }, 400); + } + + const webAppData = verifyTelegramWebAppData(body.initData, botToken); + if (!webAppData) { + clearSettingsSessionCookie(c); + return c.json({ error: "Invalid or expired Telegram data" }, 401); + } + + const userId = String(webAppData.user.id); + + // DM validation: chatId must equal userId + const chatIdNum = Number(chatId); + if (chatIdNum > 0 && chatId !== userId) { + return c.json({ error: "Chat ID mismatch" }, 403); + } + + // Create a server-side session (replaces the old encrypt-to-cookie approach) + const sessionTtlMs = 60 * 60 * 1000; + const { sessionId } = await config.sessionStore.createSession( + { + userId, + platform: "telegram", + channelId: chatId, + }, + sessionTtlMs, + ); + + const payload = await config.sessionStore.getSession(sessionId); + if (!payload) { + clearSettingsSessionCookie(c); + return c.json({ error: "Failed to create session" }, 500); + } + + setSettingsSessionCookie(c, sessionId, payload); + return c.json({ success: true }); + } + + // Path B: Session-based authentication (opaque session ID) + const sessionId = (body.sessionId ?? "").trim(); + if (!sessionId) return c.json({ error: "Missing session ID" }, 400); + + const payload = await config.sessionStore.getSession(sessionId); + if (!payload) { + clearSettingsSessionCookie(c); + return c.json({ error: "Invalid or expired session" }, 401); + } + + // If OAuth provider is configured, redirect to OAuth instead of setting cookie directly + if (config.oauthProvider) { + const authUrl = await config.oauthProvider.startAuth( + payload.userId, + sessionId, + payload.platform, + ); + return c.json({ oauthRedirect: authUrl }); + } + + setSettingsSessionCookie(c, sessionId, payload); + return c.json({ success: true }); + }); + + // ========================================================================= + // GET /settings/oauth/callback — OAuth identity verification callback + // ========================================================================= + if (config.oauthProvider && config.identityStore) { + const oauthProvider = config.oauthProvider; + const identityStore = config.identityStore; + + app.get("/settings/oauth/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + const error = c.req.query("error"); + + if (error) { + logger.warn("Settings OAuth error", { + error, + description: c.req.query("error_description"), + }); + return c.html( + renderErrorPage( + `Authentication failed: ${error}. Please request a new settings link.`, + ), + 401, + ); + } + + if (!code || !state) { + return c.html( + renderErrorPage("Invalid OAuth callback (missing code or state)."), + 400, + ); + } + + try { + const result = await oauthProvider.handleCallback(code, state); + if (!result) { + return c.html( + renderErrorPage( + "Authentication failed. The link may have expired — request a new one.", + ), + 401, + ); + } + + const { stateData, userInfo } = result; + + // Verify/establish identity mapping + const { linked, existingUserId } = await identityStore.linkIdentity( + oauthProvider.providerName, + userInfo.sub, + stateData.userId, + stateData.platform, + ); + + if (!linked) { + logger.warn("OAuth identity mismatch", { + oauthSub: userInfo.sub, + sessionUserId: stateData.userId, + existingUserId, + }); + return c.html( + renderErrorPage( + "This OAuth account is already linked to a different user.", + ), + 403, + ); + } + + // Load the session and set the cookie + const payload = await config.sessionStore.getSession( + stateData.sessionId, + ); + if (!payload) { + return c.html( + renderErrorPage("Session expired. Please request a new link."), + 401, + ); + } + + setSettingsSessionCookie(c, stateData.sessionId, payload); + return c.redirect("/settings", 303); + } catch (err) { + logger.error("Settings OAuth callback failed", { error: err }); + return c.html( + renderErrorPage( + "Authentication failed due to a server error. Please try again.", + ), + 500, + ); + } + }); + } + + // ========================================================================= + // GET /settings — HTML Settings Page + // ========================================================================= + app.get("/settings", async (c) => { + c.header("Referrer-Policy", "no-referrer"); + c.header("Cache-Control", "no-store, max-age=0"); + c.header("Pragma", "no-cache"); + + // Handle ?s= query param: validate session, set cookie, redirect clean + const querySessionId = c.req.query("s"); + if (querySessionId) { + const payload = await config.sessionStore.getSession(querySessionId); + if (!payload) { + clearSettingsSessionCookie(c); + return c.html( + renderErrorPage( + "Invalid or expired link. Use /configure to request a new settings link.", + ), + 401, + ); + } + + // If OAuth configured, redirect through OAuth first + if (config.oauthProvider) { + const authUrl = await config.oauthProvider.startAuth( + payload.userId, + querySessionId, + payload.platform, + ); + return c.redirect(authUrl, 303); + } + + setSettingsSessionCookie(c, querySessionId, payload); + return c.redirect("/settings", 303); + } + + // Telegram stable URLs: /settings?platform=telegram&chat= + // These need client-side bootstrap to extract Telegram initData from the hash + const isTelegramStableUrl = + c.req.query("platform") === "telegram" && c.req.query("chat"); + if (isTelegramStableUrl) { + return c.html(renderTelegramBootstrapPage()); + } + + const payload = await verifySettingsSession(c); + if (!payload) { + return c.html( + renderErrorPage( + "Your session has expired or is invalid. Use /configure to request a new settings link.", + ), + 401, + ); + } + + // Determine the agentId to show settings for + let agentId = payload.agentId; + + if (!agentId && payload.channelId) { + // Channel-based entry: try to resolve via existing binding + const binding = await config.channelBindingService.getBinding( + payload.platform, + payload.channelId, + payload.teamId, + ); + if (binding) { + agentId = binding.agentId; + } + } + + if (!agentId) { + // No agent resolved: show agent picker / creation form + const agentIds = await config.userAgentsStore.listAgents( + payload.platform, + payload.userId, + ); + + const agents: (AgentMetadata & { channelCount: number })[] = []; + for (const id of agentIds) { + const metadata = await config.agentMetadataStore.getMetadata(id); + if (metadata) { + const bindings = await config.channelBindingService.listBindings(id); + agents.push({ ...metadata, channelCount: bindings.length }); + } + } + + return c.html(renderPickerPage(payload, agents)); + } + + // We have an agentId: render settings page + const [settings, agentMetadata] = await Promise.all([ + config.agentSettingsStore.getSettings(agentId), + config.agentMetadataStore.getMetadata(agentId), + ]); + + // Build provider metadata from registry + const allModules = getModelProviderModules(); + const allProviderMeta = allModules + .filter((m) => m.catalogVisible !== false) + .map(buildProviderMeta); + + // Resolve installed providers in order + const installedIds = (settings?.installedProviders || []).map( + (ip) => ip.providerId, + ); + const installedSet = new Set(installedIds); + const installedProviders = installedIds + .map((id) => allProviderMeta.find((p) => p.id === id)) + .filter((p): p is ProviderMeta => p !== undefined); + + // Catalog providers = all that are not installed + const catalogProviders = allProviderMeta.filter( + (p) => !installedSet.has(p.id), + ); + + const providerModelOptions = await collectProviderModelOptions( + agentId, + payload.userId, + ); + + // Determine if agent switcher should be shown + const showSwitcher = !!payload.channelId; + + // Get agents list for switcher (only if switcher is enabled) + const agents: (AgentMetadata & { channelCount: number })[] = []; + if (showSwitcher) { + const agentIds = await config.userAgentsStore.listAgents( + payload.platform, + payload.userId, + ); + for (const id of agentIds) { + const metadata = await config.agentMetadataStore.getMetadata(id); + if (metadata) { + const bindings = await config.channelBindingService.listBindings(id); + agents.push({ ...metadata, channelCount: bindings.length }); + } + } + + // Ensure the currently active agent appears in switcher even when it is + // not part of the user's direct agent list (e.g. workspace-bound agent). + if ( + agentMetadata && + !agents.some((agent) => agent.agentId === agentMetadata.agentId) + ) { + const bindings = await config.channelBindingService.listBindings( + agentMetadata.agentId, + ); + agents.unshift({ ...agentMetadata, channelCount: bindings.length }); + } + } + + // Load system skills to prepend to initial skills + let systemSkills: import("@lobu/core").SkillConfig[] = []; + if (config.systemSkillsService) { + try { + systemSkills = await config.systemSkillsService.getSystemSkills(); + } catch { + // System skills service may fail, continue without them + } + } + + // Fetch integration status keyed by integration ID + const integrationStatus: Record< + string, + { + connected: boolean; + accounts: { accountId: string; grantedScopes: string[] }[]; + availableScopes: string[]; + } + > = {}; + if (config.integrationConfigService && config.integrationCredentialStore) { + try { + const allConfigs = await config.integrationConfigService.getAll(); + for (const [id, cfg] of Object.entries(allConfigs)) { + const accountList = + await config.integrationCredentialStore.listAccounts(agentId, id); + integrationStatus[id] = { + connected: accountList.length > 0, + accounts: accountList.map((a) => ({ + accountId: a.accountId, + grantedScopes: a.credentials.grantedScopes, + })), + availableScopes: cfg.scopes?.available ?? [], + }; + } + } catch { + // Integration services may not be configured + } + } + + // Ensure the payload has agentId for the template (may have been resolved from binding) + const effectivePayload = { ...payload, agentId }; + + return c.html( + renderSettingsPage(effectivePayload, settings, { + providers: installedProviders, + catalogProviders, + providerModelOptions, + showSwitcher, + agents, + agentName: agentMetadata?.name, + agentDescription: agentMetadata?.description, + hasChannelId: !!payload.channelId, + systemSkills, + integrationStatus, + }), + ); + }); + + // Disconnect an OAuth integration account + app.post("/api/v1/integrations/oauth/disconnect", async (c) => { + const session = await verifySettingsSession(c); + if (!session) return c.json({ error: "Not authenticated" }, 401); + + const { agentId, integrationId, accountId } = await c.req.json<{ + agentId: string; + integrationId: string; + accountId?: string; + }>(); + + if (!agentId || !integrationId) { + return c.json({ error: "Missing agentId or integrationId" }, 400); + } + + if (!config.integrationCredentialStore) { + return c.json({ error: "Integration services not configured" }, 500); + } + + await config.integrationCredentialStore.deleteCredentials( + agentId, + integrationId, + accountId || "default", + ); + + // Notify active workers so they get updated integration status + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes: [`integration:${integrationId}:disconnected`], + }); + + return c.json({ success: true }); + }); + + // Save an API key for an api-key integration + app.post("/api/v1/integrations/apikey/save", async (c) => { + const session = await verifySettingsSession(c); + if (!session) return c.json({ error: "Not authenticated" }, 401); + + const { agentId, integrationId, apiKey } = await c.req.json<{ + agentId: string; + integrationId: string; + apiKey: string; + }>(); + + if (!agentId || !integrationId || !apiKey) { + return c.json( + { error: "Missing agentId, integrationId, or apiKey" }, + 400, + ); + } + + if ( + !config.integrationConfigService || + !config.integrationCredentialStore + ) { + return c.json({ error: "Integration services not configured" }, 500); + } + + // Verify the integration exists and is api-key type + const integrationConfig = + await config.integrationConfigService.getIntegration( + integrationId, + agentId, + ); + if (!integrationConfig) { + return c.json({ error: "Integration not found" }, 404); + } + if ((integrationConfig.authType || "oauth") !== "api-key") { + return c.json({ error: "Integration is not an API key type" }, 400); + } + + // Store the API key as a credential + await config.integrationCredentialStore.setCredentials( + agentId, + integrationId, + { + accessToken: apiKey, + tokenType: "api-key", + grantedScopes: [], + }, + ); + + // Notify active workers + config.connectionManager?.notifyAgent(agentId, "config_changed", { + changes: [`integration:${integrationId}:default:connected`], + }); + + return c.json({ success: true }); + }); + + return app; } diff --git a/packages/gateway/src/services/core-services.ts b/packages/gateway/src/services/core-services.ts index 8d8dd2a97..a18e53d49 100644 --- a/packages/gateway/src/services/core-services.ts +++ b/packages/gateway/src/services/core-services.ts @@ -19,9 +19,9 @@ import { McpProxy } from "../auth/mcp/proxy"; import { McpToolCache } from "../auth/mcp/tool-cache"; import { OAuthDiscoveryService } from "../auth/oauth/discovery"; import { - type ClaudeOAuthStateStore, - createClaudeOAuthStateStore, - createMcpOAuthStateStore, + type ClaudeOAuthStateStore, + createClaudeOAuthStateStore, + createMcpOAuthStateStore, } from "../auth/oauth/state-store"; import { ProviderCatalogService } from "../auth/provider-catalog"; import { AgentSettingsStore, AuthProfilesManager } from "../auth/settings"; @@ -35,15 +35,15 @@ import type { GatewayConfig } from "../config"; import { WorkerGateway } from "../gateway"; import type { IMessageQueue } from "../infrastructure/queue"; import { - QueueProducer, - RedisQueue, - type RedisQueueConfig, + QueueProducer, + RedisQueue, + type RedisQueueConfig, } from "../infrastructure/queue"; import { InteractionService } from "../interactions"; import { getModelProviderModules } from "../modules/module-system"; import { - ScheduledWakeupService, - setScheduledWakeupService, + ScheduledWakeupService, + setScheduledWakeupService, } from "../orchestration/scheduled-wakeup"; import { GrantStore } from "../permissions/grant-store"; import { SecretProxy } from "../proxy/secret-proxy"; @@ -70,854 +70,855 @@ const logger = createLogger("core-services"); * Initialization order is important - dependencies are initialized in sequence. */ export class CoreServices { - // ============================================================================ - // Queue Services - // ============================================================================ - private queue?: IMessageQueue; - private queueProducer?: QueueProducer; - - // ============================================================================ - // Session Services - // ============================================================================ - private sessionManager?: SessionManager; - private instructionService?: InstructionService; - private interactionService?: InteractionService; - - // ============================================================================ - // Claude Services - // ============================================================================ - private authProfilesManager?: AuthProfilesManager; - private claudeModelPreferenceStore?: ClaudeModelPreferenceStore; - private claudeOAuthStateStore?: ClaudeOAuthStateStore; - private secretProxy?: SecretProxy; - private tokenRefreshJob?: TokenRefreshJob; - - // ============================================================================ - // MCP Services - // ============================================================================ - private mcpConfigService?: McpConfigService; - private mcpProxy?: McpProxy; - - // ============================================================================ - // Permissions - // ============================================================================ - private grantStore?: GrantStore; - - // ============================================================================ - // OAuth Modules - // ============================================================================ - private mcpOAuthModule?: McpOAuthModule; - - // ============================================================================ - // System Skills Service - // ============================================================================ - private systemSkillsService?: SystemSkillsService; - - // ============================================================================ - // Integration Services - // ============================================================================ - private integrationConfigService?: IntegrationConfigService; - private integrationCredentialStore?: IntegrationCredentialStore; - private integrationOAuthModule?: IntegrationOAuthModule; - - // ============================================================================ - // Worker Gateway - // ============================================================================ - private workerGateway?: WorkerGateway; - - // ============================================================================ - // Agent Configuration Services - // ============================================================================ - private agentSettingsStore?: AgentSettingsStore; - private channelBindingService?: ChannelBindingService; - private transcriptionService?: TranscriptionService; - private userAgentsStore?: UserAgentsStore; - private agentMetadataStore?: AgentMetadataStore; - private adminStatusCache?: AdminStatusCache; - - // ============================================================================ - // Auth Session & OAuth - // ============================================================================ - private authSessionStore?: AuthSessionStore; - private settingsOAuthProvider?: SettingsOAuthProvider; - private oauthIdentityStore?: OAuthIdentityStore; - - // ============================================================================ - // Provider Catalog - // ============================================================================ - private providerCatalogService?: ProviderCatalogService; - - // ============================================================================ - // Command Registry - // ============================================================================ - private commandRegistry?: CommandRegistry; - - // ============================================================================ - // Scheduled Wakeup Service - // ============================================================================ - private scheduledWakeupService?: ScheduledWakeupService; - - constructor(private readonly config: GatewayConfig) {} - - /** - * Initialize all core services in dependency order - */ - async initialize(): Promise { - logger.info("Initializing core services..."); - - // 1. Queue (foundation for everything else) - await this.initializeQueue(); - logger.debug("Queue initialized"); - - // 2. Session management - await this.initializeSessionServices(); - logger.debug("Session services initialized"); - - // 3. Claude authentication & API - await this.initializeClaudeServices(); - logger.debug("Claude services initialized"); - - // 4. MCP ecosystem (depends on queue and Claude services) - await this.initializeMcpServices(); - logger.debug("MCP services initialized"); - - // 4b. Integration services (depends on queue) - await this.initializeIntegrationServices(); - logger.debug("Integration services initialized"); - - // Wire integration services into worker gateway (initialized in step 4) - if ( - this.workerGateway && - this.integrationConfigService && - this.integrationCredentialStore - ) { - this.workerGateway.setIntegrationServices( - this.integrationConfigService, - this.integrationCredentialStore - ); - } - - // Wire connection manager into integration OAuth module for config_changed notifications - if (this.integrationOAuthModule && this.workerGateway) { - this.integrationOAuthModule.setConnectionManager( - this.workerGateway.getConnectionManager() - ); - } - - // 5. Queue producer (depends on queue being ready) - await this.initializeQueueProducer(); - logger.debug("Queue producer initialized"); - - // 6. Scheduled wakeup service (depends on queue) - await this.initializeScheduledWakeupService(); - logger.debug("Scheduled wakeup service initialized"); - - // 7. Command registry (depends on agent settings store) - this.initializeCommandRegistry(); - logger.debug("Command registry initialized"); - - logger.info("Core services initialized successfully"); - } - - // ============================================================================ - // 1. Queue Services Initialization - // ============================================================================ - - private async initializeQueue(): Promise { - if (!this.config.queues?.connectionString) { - throw new Error("Queue connection string is required"); - } - - const url = new URL(this.config.queues.connectionString); - if (url.protocol !== "redis:") { - throw new Error( - `Unsupported queue protocol: ${url.protocol}. Only redis:// is supported.` - ); - } - - const config: RedisQueueConfig = { - host: url.hostname, - port: Number.parseInt(url.port, 10) || 6379, - password: url.password || undefined, - db: url.pathname ? Number.parseInt(url.pathname.slice(1), 10) : 0, - maxRetriesPerRequest: 3, - }; - - this.queue = new RedisQueue(config); - await this.queue.start(); - logger.info("✅ Queue connection established"); - } - - private async initializeQueueProducer(): Promise { - if (!this.queue) { - throw new Error("Queue must be initialized before queue producer"); - } - - this.queueProducer = new QueueProducer(this.queue); - await this.queueProducer.start(); - logger.info("✅ Queue producer initialized"); - } - - // ============================================================================ - // Scheduled Wakeup Service Initialization - // ============================================================================ - - private async initializeScheduledWakeupService(): Promise { - if (!this.queue) { - throw new Error( - "Queue must be initialized before scheduled wakeup service" - ); - } - - this.scheduledWakeupService = new ScheduledWakeupService(this.queue); - await this.scheduledWakeupService.start(); - // Set global reference for BaseDeploymentManager cleanup - setScheduledWakeupService(this.scheduledWakeupService); - logger.info("✅ Scheduled wakeup service initialized"); - } - - // ============================================================================ - // 2. Session Services Initialization - // ============================================================================ - - private async initializeSessionServices(): Promise { - if (!this.queue) { - throw new Error("Queue must be initialized before session services"); - } - - const redisClient = this.queue.getRedisClient(); - - const sessionStore = new RedisSessionStore(this.queue); - this.sessionManager = new SessionManager(sessionStore); - logger.info("✅ Session manager initialized"); - - this.interactionService = new InteractionService(); - logger.info("✅ Interaction service initialized"); - - // Initialize per-deployment config stores (Redis-backed) - await mcpConfigStore.initialize(redisClient); - logger.info("✅ MCP config store initialized"); - - // Initialize grant store for unified permissions - this.grantStore = new GrantStore(redisClient); - logger.info("✅ Grant store initialized"); - - // Initialize agent configuration stores - this.agentSettingsStore = new AgentSettingsStore(redisClient); - this.channelBindingService = new ChannelBindingService(redisClient); - this.userAgentsStore = new UserAgentsStore(redisClient); - this.agentMetadataStore = new AgentMetadataStore(redisClient); - this.adminStatusCache = new AdminStatusCache(redisClient); - - // Auth session store (replaces encrypted tokens for settings, integration init) - this.authSessionStore = new AuthSessionStore(redisClient); - this.oauthIdentityStore = new OAuthIdentityStore(redisClient); - // Settings OAuth provider (optional — configured via SETTINGS_OAUTH_* env vars) - this.settingsOAuthProvider = SettingsOAuthProvider.fromEnv( - redisClient, - this.config.mcp.publicGatewayUrl - ); - - logger.info( - `✅ Agent settings, channel binding, user agents & metadata stores initialized (settings OAuth: ${this.settingsOAuthProvider ? "enabled" : "disabled"})` - ); - } - - // ============================================================================ - // 3. Claude Services Initialization - // ============================================================================ - - private async initializeClaudeServices(): Promise { - if (!this.queue) { - throw new Error("Queue must be initialized before Claude services"); - } - - const redisClient = this.queue.getRedisClient(); - - if (!this.agentSettingsStore) { - throw new Error( - "Agent settings store must be initialized before Claude services" - ); - } - - // Initialize auth profile and preference stores - this.authProfilesManager = new AuthProfilesManager(this.agentSettingsStore); - this.transcriptionService = new TranscriptionService( - this.authProfilesManager - ); - this.claudeModelPreferenceStore = new ClaudeModelPreferenceStore( - redisClient - ); - logger.info("✅ Auth profile & Claude preference stores initialized"); - - // Initialize secret injection proxy (will be finalized after provider modules are registered) - this.secretProxy = new SecretProxy({ - defaultUpstreamUrl: - this.config.anthropicProxy.anthropicBaseUrl || - "https://api.anthropic.com", - }); - this.secretProxy.initialize(redisClient); - logger.info( - `✅ Secret proxy initialized (upstream: ${this.config.anthropicProxy.anthropicBaseUrl || "https://api.anthropic.com"})` - ); - - // Start background token refresh job - if (!this.authProfilesManager) { - throw new Error( - "Auth profiles manager must be initialized before Claude services" - ); - } - this.tokenRefreshJob = new TokenRefreshJob( - this.authProfilesManager, - redisClient - ); - this.tokenRefreshJob.start(); - logger.info("✅ Token refresh job started"); - - // Register NVIDIA NIM API-key provider - const nvidiaModule = new ApiKeyProviderModule({ - providerId: "nvidia", - providerDisplayName: "NVIDIA NIM (free)", - providerIconUrl: - "https://www.google.com/s2/favicons?domain=nvidia.com&sz=128", - envVarName: "NVIDIA_API_KEY", - slug: "nvidia", - upstreamBaseUrl: "https://integrate.api.nvidia.com", - modelsEndpoint: "/v1/models", - apiKeyInstructions: - 'Get your API key from NVIDIA Build', - apiKeyPlaceholder: "nvapi-...", - agentSettingsStore: this.agentSettingsStore, - }); - moduleRegistry.register(nvidiaModule); - logger.info( - `✅ NVIDIA NIM module registered (system token: ${nvidiaModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Register Claude OAuth module - this.claudeOAuthStateStore = createClaudeOAuthStateStore(redisClient); - const claudeOAuthModule = new ClaudeOAuthModule( - this.authProfilesManager, - this.claudeOAuthStateStore, - this.claudeModelPreferenceStore, - this.queue, - this.config.mcp.publicGatewayUrl - ); - moduleRegistry.register(claudeOAuthModule); - logger.info( - `✅ Claude OAuth module registered (system token: ${claudeOAuthModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Register ChatGPT OAuth module - const chatgptOAuthModule = new ChatGPTOAuthModule(this.agentSettingsStore, { - userAgentsStore: this.userAgentsStore, - agentMetadataStore: this.agentMetadataStore, - }); - moduleRegistry.register(chatgptOAuthModule); - logger.info( - `✅ ChatGPT OAuth module registered (system token: ${chatgptOAuthModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Register Gemini API-key provider - const geminiModule = new ApiKeyProviderModule({ - providerId: "gemini", - providerDisplayName: "Google Gemini", - providerIconUrl: - "https://www.google.com/s2/favicons?domain=gemini.google.com&sz=128", - envVarName: "GEMINI_API_KEY", - slug: "gemini", - upstreamBaseUrl: "https://generativelanguage.googleapis.com", - apiKeyInstructions: - 'Get your API key from Google AI Studio', - apiKeyPlaceholder: "AIza...", - agentSettingsStore: this.agentSettingsStore, - }); - moduleRegistry.register(geminiModule); - logger.info( - `✅ Gemini module registered (system token: ${geminiModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Register z.ai API-key provider - const zaiModule = new ApiKeyProviderModule({ - providerId: "z-ai", - providerDisplayName: "z.ai", - providerIconUrl: "https://www.google.com/s2/favicons?domain=z.ai&sz=128", - envVarName: "Z_AI_API_KEY", - slug: "z-ai", - upstreamBaseUrl: "https://api.z.ai/api/coding/paas/v4", - apiKeyInstructions: - 'Get your API key from z.ai', - apiKeyPlaceholder: "zai-...", - agentSettingsStore: this.agentSettingsStore, - }); - moduleRegistry.register(zaiModule); - logger.info( - `✅ z.ai module registered (system token: ${zaiModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Register ElevenLabs API-key provider - const elevenlabsModule = new ApiKeyProviderModule({ - providerId: "elevenlabs", - providerDisplayName: "ElevenLabs", - providerIconUrl: - "https://www.google.com/s2/favicons?domain=elevenlabs.io&sz=128", - envVarName: "ELEVENLABS_API_KEY", - slug: "elevenlabs", - upstreamBaseUrl: "https://api.elevenlabs.io", - apiKeyInstructions: - 'Get your API key from ElevenLabs', - apiKeyPlaceholder: "sk_...", - agentSettingsStore: this.agentSettingsStore, - }); - moduleRegistry.register(elevenlabsModule); - logger.info( - `✅ ElevenLabs module registered (system token: ${elevenlabsModule.hasSystemKey() ? "available" : "not available"})` - ); - - // Initialize SystemSkillsService from LOBU_SYSTEM_SKILLS_URL (or legacy env vars) - const systemSkillsUrl = - process.env.LOBU_SYSTEM_SKILLS_URL || - process.env.LOBU_INTEGRATIONS_CONFIG_URL || - process.env.LOBU_INTEGRATIONS_URL; - this.systemSkillsService = new SystemSkillsService(systemSkillsUrl); - - // Register config-driven providers from system skills - if (systemSkillsUrl) { - // Create config service early if not yet initialized - if (!this.integrationConfigService) { - this.integrationConfigService = new IntegrationConfigService( - this.systemSkillsService, - this.agentSettingsStore - ); - } - const configProviders = - await this.integrationConfigService.getProviders(); - const registeredIds = new Set( - getModelProviderModules().map((m) => m.providerId) - ); - for (const [id, entry] of Object.entries(configProviders)) { - if (registeredIds.has(id)) { - logger.info( - `Skipping config-driven provider "${id}" — already registered` - ); - continue; - } - const module = new ApiKeyProviderModule({ - providerId: id, - providerDisplayName: entry.displayName, - providerIconUrl: entry.iconUrl, - envVarName: entry.envVarName, - slug: id, - upstreamBaseUrl: entry.upstreamBaseUrl, - modelsEndpoint: entry.modelsEndpoint, - sdkCompat: entry.sdkCompat, - defaultModel: entry.defaultModel, - registryAlias: entry.registryAlias, - apiKeyInstructions: entry.apiKeyInstructions, - apiKeyPlaceholder: entry.apiKeyPlaceholder, - agentSettingsStore: this.agentSettingsStore, - }); - moduleRegistry.register(module); - logger.info( - `✅ Registered config-driven provider: ${id} (system key: ${module.hasSystemKey() ? "available" : "not available"})` - ); - } - } - - // Initialize provider catalog service - this.providerCatalogService = new ProviderCatalogService( - this.agentSettingsStore, - this.authProfilesManager - ); - logger.info("✅ Provider catalog service initialized"); - - // Register provider upstream configs with the secret proxy for path-based routing - if (this.secretProxy) { - this.secretProxy.setAuthProfilesManager(this.authProfilesManager); - for (const provider of getModelProviderModules()) { - const upstream = provider.getUpstreamConfig?.(); - if (upstream) { - this.secretProxy.registerUpstream(upstream, provider.providerId); - } - } - logger.info("✅ Provider upstreams registered with secret proxy"); - } - } - - // ============================================================================ - // 4. MCP Services Initialization - // ============================================================================ - - private async initializeMcpServices(): Promise { - if (!this.queue) { - throw new Error("Queue must be initialized before MCP services"); - } - - const redisClient = this.queue.getRedisClient(); - - // Initialize MCP credential and state management - const mcpCredentialStore = new McpCredentialStore(redisClient); - const mcpOAuthStateStore = createMcpOAuthStateStore(redisClient); - const mcpInputStore = new McpInputStore(this.queue); - - // Initialize MCP OAuth discovery service - const mcpDiscoveryService = new OAuthDiscoveryService({ - cacheStore: { - get: async (key: string) => { - try { - return await redisClient.get(key); - } catch (error) { - logger.error("Failed to get from cache", { key, error }); - return null; - } - }, - set: async (key: string, value: string, ttl: number) => { - try { - await redisClient.set(key, value, "EX", ttl); - } catch (error) { - logger.error("Failed to set cache", { key, error }); - } - }, - delete: async (key: string) => { - try { - await redisClient.del(key); - } catch (error) { - logger.error("Failed to delete from cache", { key, error }); - } - }, - }, - callbackUrl: this.config.mcp.callbackUrl, - protocolVersion: "2025-03-26", - cacheTtl: 86400, - }); - logger.info("✅ MCP OAuth Discovery Service initialized"); - - // Initialize MCP config service - this.mcpConfigService = new McpConfigService({ - configUrl: this.config.mcp.serversUrl, - discoveryService: mcpDiscoveryService, - credentialStore: mcpCredentialStore, - inputStore: mcpInputStore, - agentSettingsStore: this.agentSettingsStore, - }); - - // Initialize instruction service (needed by WorkerGateway) - // Pass agentSettingsStore so skills instructions can be fetched per-agent - this.instructionService = new InstructionService( - this.mcpConfigService, - this.agentSettingsStore - ); - logger.info("Instruction service initialized"); - - // Initialize MCP tool cache and proxy (before worker gateway so it can use the proxy) - const mcpToolCache = new McpToolCache(redisClient); - this.mcpProxy = new McpProxy( - this.mcpConfigService, - mcpCredentialStore, - mcpInputStore, - this.queue, - mcpToolCache, - this.grantStore - ); - logger.info("MCP proxy initialized"); - - // Initialize worker gateway - if (!this.sessionManager) { - throw new Error( - "Session manager must be initialized before worker gateway" - ); - } - this.workerGateway = new WorkerGateway( - this.queue, - this.config.mcp.publicGatewayUrl, - this.sessionManager, - this.mcpConfigService, - this.instructionService, - this.mcpProxy, - this.providerCatalogService, - this.agentSettingsStore - ); - logger.info("Worker gateway initialized"); - - // Discover OAuth capabilities for all MCP servers - logger.info("Discovering OAuth capabilities for MCP servers..."); - await this.mcpConfigService.enrichWithDiscovery(); - logger.info("MCP OAuth discovery completed"); - - // Register MCP OAuth module - this.mcpOAuthModule = new McpOAuthModule( - this.mcpConfigService, - mcpCredentialStore, - mcpOAuthStateStore, - mcpInputStore, - this.config.mcp.publicGatewayUrl, - this.config.mcp.callbackUrl - ); - moduleRegistry.register(this.mcpOAuthModule); - logger.info("MCP OAuth module registered"); - - // Discover and initialize all available modules - await moduleRegistry.registerAvailableModules(); - await moduleRegistry.initAll(); - logger.info("Modules initialized"); - } - - // ============================================================================ - // 4b. Integration Services Initialization - // ============================================================================ - - private async initializeIntegrationServices(): Promise { - if (!this.queue) { - throw new Error("Queue must be initialized before integration services"); - } - - if (!this.systemSkillsService) { - logger.info( - "No SystemSkillsService available, integration services disabled" - ); - return; - } - - // Check if there are any integrations configured - const integrationConfigs = - await this.systemSkillsService.getAllIntegrationConfigs(); - if (Object.keys(integrationConfigs).length === 0) { - logger.info( - "No integrations found in system skills, integration services disabled" - ); - return; - } - - const redisClient = this.queue.getRedisClient(); - - if (!this.integrationConfigService) { - this.integrationConfigService = new IntegrationConfigService( - this.systemSkillsService, - this.agentSettingsStore - ); - } - this.integrationCredentialStore = new IntegrationCredentialStore( - redisClient - ); - - // Reuse MCP OAuth state store for integration OAuth flows - const mcpOAuthStateStore = createMcpOAuthStateStore(redisClient); - - const callbackUrl = `${this.config.mcp.publicGatewayUrl}/api/v1/auth/integration/callback`; - - if (!this.authSessionStore) { - throw new Error( - "Auth session store must be initialized before integration services" - ); - } - - this.integrationOAuthModule = new IntegrationOAuthModule( - this.integrationConfigService, - this.integrationCredentialStore, - mcpOAuthStateStore, - this.authSessionStore, - this.config.mcp.publicGatewayUrl, - callbackUrl, - this.queue - ); - - logger.info( - `✅ Integration services initialized (${Object.keys(integrationConfigs).length} integration(s))` - ); - } - - // ============================================================================ - // 7. Command Registry Initialization - // ============================================================================ - - private initializeCommandRegistry(): void { - if (!this.agentSettingsStore) { - throw new Error( - "Agent settings store must be initialized before command registry" - ); - } - - this.commandRegistry = new CommandRegistry(); - registerBuiltInCommands(this.commandRegistry, { - agentSettingsStore: this.agentSettingsStore, - }); - logger.info("✅ Command registry initialized with built-in commands"); - } - - // ============================================================================ - // Shutdown - // ============================================================================ - - async shutdown(): Promise { - logger.info("Shutting down core services..."); - - if (this.tokenRefreshJob) { - this.tokenRefreshJob.stop(); - } - - if (this.queueProducer) { - await this.queueProducer.stop(); - } - - if (this.workerGateway) { - this.workerGateway.shutdown(); - logger.info("Worker gateway shutdown complete"); - } - - if (this.queue) { - await this.queue.stop(); - } - - logger.info("✅ Core services shutdown complete"); - } - - // ============================================================================ - // Service Accessors (implements ICoreServices interface) - // ============================================================================ - - getQueue(): IMessageQueue { - if (!this.queue) throw new Error("Queue not initialized"); - return this.queue; - } - - getQueueProducer(): QueueProducer { - if (!this.queueProducer) throw new Error("Queue producer not initialized"); - return this.queueProducer; - } - - getSecretProxy(): SecretProxy | undefined { - return this.secretProxy; - } - - getWorkerGateway(): WorkerGateway | undefined { - return this.workerGateway; - } - - getMcpProxy(): McpProxy | undefined { - return this.mcpProxy; - } - - getMcpConfigService(): McpConfigService | undefined { - return this.mcpConfigService; - } - - getClaudeModelPreferenceStore(): ClaudeModelPreferenceStore | undefined { - return this.claudeModelPreferenceStore; - } - - getClaudeOAuthStateStore(): ClaudeOAuthStateStore | undefined { - return this.claudeOAuthStateStore; - } - - getPublicGatewayUrl(): string { - return this.config.mcp.publicGatewayUrl; - } - - getSessionManager(): SessionManager { - if (!this.sessionManager) - throw new Error("Session manager not initialized"); - return this.sessionManager; - } - - getInstructionService(): InstructionService | undefined { - return this.instructionService; - } - - getInteractionService(): InteractionService { - if (!this.interactionService) - throw new Error("Interaction service not initialized"); - return this.interactionService; - } - - getMcpOAuthModule(): McpOAuthModule | undefined { - return this.mcpOAuthModule; - } - - getAgentSettingsStore(): AgentSettingsStore { - if (!this.agentSettingsStore) - throw new Error("Agent settings store not initialized"); - return this.agentSettingsStore; - } - - getChannelBindingService(): ChannelBindingService { - if (!this.channelBindingService) - throw new Error("Channel binding service not initialized"); - return this.channelBindingService; - } - - getScheduledWakeupService(): ScheduledWakeupService | undefined { - return this.scheduledWakeupService; - } - - getTranscriptionService(): TranscriptionService | undefined { - return this.transcriptionService; - } - - getUserAgentsStore(): UserAgentsStore { - if (!this.userAgentsStore) - throw new Error("User agents store not initialized"); - return this.userAgentsStore; - } - - getAgentMetadataStore(): AgentMetadataStore { - if (!this.agentMetadataStore) - throw new Error("Agent metadata store not initialized"); - return this.agentMetadataStore; - } - - getAdminStatusCache(): AdminStatusCache { - if (!this.adminStatusCache) - throw new Error("Admin status cache not initialized"); - return this.adminStatusCache; - } - - getCommandRegistry(): CommandRegistry { - if (!this.commandRegistry) - throw new Error("Command registry not initialized"); - return this.commandRegistry; - } - - getProviderCatalogService(): ProviderCatalogService { - if (!this.providerCatalogService) - throw new Error("Provider catalog service not initialized"); - return this.providerCatalogService; - } - - getAuthProfilesManager(): AuthProfilesManager | undefined { - return this.authProfilesManager; - } - - getGrantStore(): GrantStore | undefined { - return this.grantStore; - } - - getSystemSkillsService(): SystemSkillsService | undefined { - return this.systemSkillsService; - } - - getIntegrationConfigService(): IntegrationConfigService | undefined { - return this.integrationConfigService; - } - - getIntegrationCredentialStore(): IntegrationCredentialStore | undefined { - return this.integrationCredentialStore; - } - - getIntegrationOAuthModule(): IntegrationOAuthModule | undefined { - return this.integrationOAuthModule; - } - - getAuthSessionStore(): AuthSessionStore { - if (!this.authSessionStore) - throw new Error("Auth session store not initialized"); - return this.authSessionStore; - } - - getSettingsOAuthProvider(): SettingsOAuthProvider | undefined { - return this.settingsOAuthProvider; - } - - getOAuthIdentityStore(): OAuthIdentityStore | undefined { - return this.oauthIdentityStore; - } + // ============================================================================ + // Queue Services + // ============================================================================ + private queue?: IMessageQueue; + private queueProducer?: QueueProducer; + + // ============================================================================ + // Session Services + // ============================================================================ + private sessionManager?: SessionManager; + private instructionService?: InstructionService; + private interactionService?: InteractionService; + + // ============================================================================ + // Claude Services + // ============================================================================ + private authProfilesManager?: AuthProfilesManager; + private claudeModelPreferenceStore?: ClaudeModelPreferenceStore; + private claudeOAuthStateStore?: ClaudeOAuthStateStore; + private secretProxy?: SecretProxy; + private tokenRefreshJob?: TokenRefreshJob; + + // ============================================================================ + // MCP Services + // ============================================================================ + private mcpConfigService?: McpConfigService; + private mcpProxy?: McpProxy; + + // ============================================================================ + // Permissions + // ============================================================================ + private grantStore?: GrantStore; + + // ============================================================================ + // OAuth Modules + // ============================================================================ + private mcpOAuthModule?: McpOAuthModule; + + // ============================================================================ + // System Skills Service + // ============================================================================ + private systemSkillsService?: SystemSkillsService; + + // ============================================================================ + // Integration Services + // ============================================================================ + private integrationConfigService?: IntegrationConfigService; + private integrationCredentialStore?: IntegrationCredentialStore; + private integrationOAuthModule?: IntegrationOAuthModule; + + // ============================================================================ + // Worker Gateway + // ============================================================================ + private workerGateway?: WorkerGateway; + + // ============================================================================ + // Agent Configuration Services + // ============================================================================ + private agentSettingsStore?: AgentSettingsStore; + private channelBindingService?: ChannelBindingService; + private transcriptionService?: TranscriptionService; + private userAgentsStore?: UserAgentsStore; + private agentMetadataStore?: AgentMetadataStore; + private adminStatusCache?: AdminStatusCache; + + // ============================================================================ + // Auth Session & OAuth + // ============================================================================ + private authSessionStore?: AuthSessionStore; + private settingsOAuthProvider?: SettingsOAuthProvider; + private oauthIdentityStore?: OAuthIdentityStore; + + // ============================================================================ + // Provider Catalog + // ============================================================================ + private providerCatalogService?: ProviderCatalogService; + + // ============================================================================ + // Command Registry + // ============================================================================ + private commandRegistry?: CommandRegistry; + + // ============================================================================ + // Scheduled Wakeup Service + // ============================================================================ + private scheduledWakeupService?: ScheduledWakeupService; + + constructor(private readonly config: GatewayConfig) {} + + /** + * Initialize all core services in dependency order + */ + async initialize(): Promise { + logger.info("Initializing core services..."); + + // 1. Queue (foundation for everything else) + await this.initializeQueue(); + logger.debug("Queue initialized"); + + // 2. Session management + await this.initializeSessionServices(); + logger.debug("Session services initialized"); + + // 3. Claude authentication & API + await this.initializeClaudeServices(); + logger.debug("Claude services initialized"); + + // 4. MCP ecosystem (depends on queue and Claude services) + await this.initializeMcpServices(); + logger.debug("MCP services initialized"); + + // 4b. Integration services (depends on queue) + await this.initializeIntegrationServices(); + logger.debug("Integration services initialized"); + + // Wire integration services into worker gateway (initialized in step 4) + if ( + this.workerGateway && + this.integrationConfigService && + this.integrationCredentialStore + ) { + this.workerGateway.setIntegrationServices( + this.integrationConfigService, + this.integrationCredentialStore, + ); + } + + // Wire connection manager into integration OAuth module for config_changed notifications + if (this.integrationOAuthModule && this.workerGateway) { + this.integrationOAuthModule.setConnectionManager( + this.workerGateway.getConnectionManager(), + ); + } + + // 5. Queue producer (depends on queue being ready) + await this.initializeQueueProducer(); + logger.debug("Queue producer initialized"); + + // 6. Scheduled wakeup service (depends on queue) + await this.initializeScheduledWakeupService(); + logger.debug("Scheduled wakeup service initialized"); + + // 7. Command registry (depends on agent settings store) + this.initializeCommandRegistry(); + logger.debug("Command registry initialized"); + + logger.info("Core services initialized successfully"); + } + + // ============================================================================ + // 1. Queue Services Initialization + // ============================================================================ + + private async initializeQueue(): Promise { + if (!this.config.queues?.connectionString) { + throw new Error("Queue connection string is required"); + } + + const url = new URL(this.config.queues.connectionString); + if (url.protocol !== "redis:") { + throw new Error( + `Unsupported queue protocol: ${url.protocol}. Only redis:// is supported.`, + ); + } + + const config: RedisQueueConfig = { + host: url.hostname, + port: Number.parseInt(url.port, 10) || 6379, + password: url.password || undefined, + db: url.pathname ? Number.parseInt(url.pathname.slice(1), 10) : 0, + maxRetriesPerRequest: 3, + }; + + this.queue = new RedisQueue(config); + await this.queue.start(); + logger.info("✅ Queue connection established"); + } + + private async initializeQueueProducer(): Promise { + if (!this.queue) { + throw new Error("Queue must be initialized before queue producer"); + } + + this.queueProducer = new QueueProducer(this.queue); + await this.queueProducer.start(); + logger.info("✅ Queue producer initialized"); + } + + // ============================================================================ + // Scheduled Wakeup Service Initialization + // ============================================================================ + + private async initializeScheduledWakeupService(): Promise { + if (!this.queue) { + throw new Error( + "Queue must be initialized before scheduled wakeup service", + ); + } + + this.scheduledWakeupService = new ScheduledWakeupService(this.queue); + await this.scheduledWakeupService.start(); + // Set global reference for BaseDeploymentManager cleanup + setScheduledWakeupService(this.scheduledWakeupService); + logger.info("✅ Scheduled wakeup service initialized"); + } + + // ============================================================================ + // 2. Session Services Initialization + // ============================================================================ + + private async initializeSessionServices(): Promise { + if (!this.queue) { + throw new Error("Queue must be initialized before session services"); + } + + const redisClient = this.queue.getRedisClient(); + + const sessionStore = new RedisSessionStore(this.queue); + this.sessionManager = new SessionManager(sessionStore); + logger.info("✅ Session manager initialized"); + + this.interactionService = new InteractionService(); + logger.info("✅ Interaction service initialized"); + + // Initialize per-deployment config stores (Redis-backed) + await mcpConfigStore.initialize(redisClient); + logger.info("✅ MCP config store initialized"); + + // Initialize grant store for unified permissions + this.grantStore = new GrantStore(redisClient); + logger.info("✅ Grant store initialized"); + + // Initialize agent configuration stores + this.agentSettingsStore = new AgentSettingsStore(redisClient); + this.channelBindingService = new ChannelBindingService(redisClient); + this.userAgentsStore = new UserAgentsStore(redisClient); + this.agentMetadataStore = new AgentMetadataStore(redisClient); + this.adminStatusCache = new AdminStatusCache(redisClient); + + // Auth session store (replaces encrypted tokens for settings, integration init) + this.authSessionStore = new AuthSessionStore(redisClient); + this.oauthIdentityStore = new OAuthIdentityStore(redisClient); + // Settings OAuth provider (optional — configured via SETTINGS_OAUTH_* env vars) + this.settingsOAuthProvider = SettingsOAuthProvider.fromEnv( + redisClient, + this.config.mcp.publicGatewayUrl, + ); + + logger.info( + `✅ Agent settings, channel binding, user agents & metadata stores initialized (settings OAuth: ${this.settingsOAuthProvider ? "enabled" : "disabled"})`, + ); + } + + // ============================================================================ + // 3. Claude Services Initialization + // ============================================================================ + + private async initializeClaudeServices(): Promise { + if (!this.queue) { + throw new Error("Queue must be initialized before Claude services"); + } + + const redisClient = this.queue.getRedisClient(); + + if (!this.agentSettingsStore) { + throw new Error( + "Agent settings store must be initialized before Claude services", + ); + } + + // Initialize auth profile and preference stores + this.authProfilesManager = new AuthProfilesManager(this.agentSettingsStore); + this.transcriptionService = new TranscriptionService( + this.authProfilesManager, + ); + this.claudeModelPreferenceStore = new ClaudeModelPreferenceStore( + redisClient, + ); + logger.info("✅ Auth profile & Claude preference stores initialized"); + + // Initialize secret injection proxy (will be finalized after provider modules are registered) + this.secretProxy = new SecretProxy({ + defaultUpstreamUrl: + this.config.anthropicProxy.anthropicBaseUrl || + "https://api.anthropic.com", + }); + this.secretProxy.initialize(redisClient); + logger.info( + `✅ Secret proxy initialized (upstream: ${this.config.anthropicProxy.anthropicBaseUrl || "https://api.anthropic.com"})`, + ); + + // Start background token refresh job + if (!this.authProfilesManager) { + throw new Error( + "Auth profiles manager must be initialized before Claude services", + ); + } + this.tokenRefreshJob = new TokenRefreshJob( + this.authProfilesManager, + redisClient, + ); + this.tokenRefreshJob.start(); + logger.info("✅ Token refresh job started"); + + // Register NVIDIA NIM API-key provider + const nvidiaModule = new ApiKeyProviderModule({ + providerId: "nvidia", + providerDisplayName: "NVIDIA NIM (free)", + providerIconUrl: + "https://www.google.com/s2/favicons?domain=nvidia.com&sz=128", + envVarName: "NVIDIA_API_KEY", + slug: "nvidia", + upstreamBaseUrl: "https://integrate.api.nvidia.com", + modelsEndpoint: "/v1/models", + apiKeyInstructions: + 'Get your API key from NVIDIA Build', + apiKeyPlaceholder: "nvapi-...", + agentSettingsStore: this.agentSettingsStore, + }); + moduleRegistry.register(nvidiaModule); + logger.info( + `✅ NVIDIA NIM module registered (system token: ${nvidiaModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Register Claude OAuth module + this.claudeOAuthStateStore = createClaudeOAuthStateStore(redisClient); + const claudeOAuthModule = new ClaudeOAuthModule( + this.authProfilesManager, + this.claudeOAuthStateStore, + this.claudeModelPreferenceStore, + this.queue, + this.config.mcp.publicGatewayUrl, + ); + moduleRegistry.register(claudeOAuthModule); + logger.info( + `✅ Claude OAuth module registered (system token: ${claudeOAuthModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Register ChatGPT OAuth module + const chatgptOAuthModule = new ChatGPTOAuthModule(this.agentSettingsStore, { + userAgentsStore: this.userAgentsStore, + agentMetadataStore: this.agentMetadataStore, + }); + moduleRegistry.register(chatgptOAuthModule); + logger.info( + `✅ ChatGPT OAuth module registered (system token: ${chatgptOAuthModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Register Gemini API-key provider + const geminiModule = new ApiKeyProviderModule({ + providerId: "gemini", + providerDisplayName: "Google Gemini", + providerIconUrl: + "https://www.google.com/s2/favicons?domain=gemini.google.com&sz=128", + envVarName: "GEMINI_API_KEY", + slug: "gemini", + upstreamBaseUrl: "https://generativelanguage.googleapis.com", + apiKeyInstructions: + 'Get your API key from Google AI Studio', + apiKeyPlaceholder: "AIza...", + agentSettingsStore: this.agentSettingsStore, + }); + moduleRegistry.register(geminiModule); + logger.info( + `✅ Gemini module registered (system token: ${geminiModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Register z.ai API-key provider + const zaiModule = new ApiKeyProviderModule({ + providerId: "z-ai", + providerDisplayName: "z.ai", + providerIconUrl: "https://www.google.com/s2/favicons?domain=z.ai&sz=128", + envVarName: "Z_AI_API_KEY", + slug: "z-ai", + upstreamBaseUrl: "https://api.z.ai/api/coding/paas/v4", + apiKeyInstructions: + 'Get your API key from z.ai', + apiKeyPlaceholder: "zai-...", + agentSettingsStore: this.agentSettingsStore, + }); + moduleRegistry.register(zaiModule); + logger.info( + `✅ z.ai module registered (system token: ${zaiModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Register ElevenLabs API-key provider + const elevenlabsModule = new ApiKeyProviderModule({ + providerId: "elevenlabs", + providerDisplayName: "ElevenLabs", + providerIconUrl: + "https://www.google.com/s2/favicons?domain=elevenlabs.io&sz=128", + envVarName: "ELEVENLABS_API_KEY", + slug: "elevenlabs", + upstreamBaseUrl: "https://api.elevenlabs.io", + apiKeyInstructions: + 'Get your API key from ElevenLabs', + apiKeyPlaceholder: "sk_...", + agentSettingsStore: this.agentSettingsStore, + }); + moduleRegistry.register(elevenlabsModule); + logger.info( + `✅ ElevenLabs module registered (system token: ${elevenlabsModule.hasSystemKey() ? "available" : "not available"})`, + ); + + // Initialize SystemSkillsService from LOBU_SYSTEM_SKILLS_URL (or legacy env vars) + const systemSkillsUrl = + process.env.LOBU_SYSTEM_SKILLS_URL || + process.env.LOBU_INTEGRATIONS_CONFIG_URL || + process.env.LOBU_INTEGRATIONS_URL; + this.systemSkillsService = new SystemSkillsService(systemSkillsUrl); + + // Register config-driven providers from system skills + if (systemSkillsUrl) { + // Create config service early if not yet initialized + if (!this.integrationConfigService) { + this.integrationConfigService = new IntegrationConfigService( + this.systemSkillsService, + this.agentSettingsStore, + ); + } + const configProviders = + await this.integrationConfigService.getProviders(); + const registeredIds = new Set( + getModelProviderModules().map((m) => m.providerId), + ); + for (const [id, entry] of Object.entries(configProviders)) { + if (registeredIds.has(id)) { + logger.info( + `Skipping config-driven provider "${id}" — already registered`, + ); + continue; + } + const module = new ApiKeyProviderModule({ + providerId: id, + providerDisplayName: entry.displayName, + providerIconUrl: entry.iconUrl, + envVarName: entry.envVarName, + slug: id, + upstreamBaseUrl: entry.upstreamBaseUrl, + modelsEndpoint: entry.modelsEndpoint, + sdkCompat: entry.sdkCompat, + defaultModel: entry.defaultModel, + registryAlias: entry.registryAlias, + apiKeyInstructions: entry.apiKeyInstructions, + apiKeyPlaceholder: entry.apiKeyPlaceholder, + agentSettingsStore: this.agentSettingsStore, + }); + moduleRegistry.register(module); + logger.info( + `✅ Registered config-driven provider: ${id} (system key: ${module.hasSystemKey() ? "available" : "not available"})`, + ); + } + } + + // Initialize provider catalog service + this.providerCatalogService = new ProviderCatalogService( + this.agentSettingsStore, + this.authProfilesManager, + ); + logger.info("✅ Provider catalog service initialized"); + + // Register provider upstream configs with the secret proxy for path-based routing + if (this.secretProxy) { + this.secretProxy.setAuthProfilesManager(this.authProfilesManager); + for (const provider of getModelProviderModules()) { + const upstream = provider.getUpstreamConfig?.(); + if (upstream) { + this.secretProxy.registerUpstream(upstream, provider.providerId); + } + } + logger.info("✅ Provider upstreams registered with secret proxy"); + } + } + + // ============================================================================ + // 4. MCP Services Initialization + // ============================================================================ + + private async initializeMcpServices(): Promise { + if (!this.queue) { + throw new Error("Queue must be initialized before MCP services"); + } + + const redisClient = this.queue.getRedisClient(); + + // Initialize MCP credential and state management + const mcpCredentialStore = new McpCredentialStore(redisClient); + const mcpOAuthStateStore = createMcpOAuthStateStore(redisClient); + const mcpInputStore = new McpInputStore(this.queue); + + // Initialize MCP OAuth discovery service + const mcpDiscoveryService = new OAuthDiscoveryService({ + cacheStore: { + get: async (key: string) => { + try { + return await redisClient.get(key); + } catch (error) { + logger.error("Failed to get from cache", { key, error }); + return null; + } + }, + set: async (key: string, value: string, ttl: number) => { + try { + await redisClient.set(key, value, "EX", ttl); + } catch (error) { + logger.error("Failed to set cache", { key, error }); + } + }, + delete: async (key: string) => { + try { + await redisClient.del(key); + } catch (error) { + logger.error("Failed to delete from cache", { key, error }); + } + }, + }, + callbackUrl: this.config.mcp.callbackUrl, + protocolVersion: "2025-03-26", + cacheTtl: 86400, + }); + logger.info("✅ MCP OAuth Discovery Service initialized"); + + // Initialize MCP config service + this.mcpConfigService = new McpConfigService({ + configUrl: this.config.mcp.serversUrl, + discoveryService: mcpDiscoveryService, + credentialStore: mcpCredentialStore, + inputStore: mcpInputStore, + agentSettingsStore: this.agentSettingsStore, + }); + + // Initialize instruction service (needed by WorkerGateway) + // Pass agentSettingsStore so skills instructions can be fetched per-agent + this.instructionService = new InstructionService( + this.mcpConfigService, + this.agentSettingsStore, + ); + logger.info("Instruction service initialized"); + + // Initialize MCP tool cache and proxy (before worker gateway so it can use the proxy) + const mcpToolCache = new McpToolCache(redisClient); + this.mcpProxy = new McpProxy( + this.mcpConfigService, + mcpCredentialStore, + mcpInputStore, + this.queue, + mcpToolCache, + this.grantStore, + ); + logger.info("MCP proxy initialized"); + + // Initialize worker gateway + if (!this.sessionManager) { + throw new Error( + "Session manager must be initialized before worker gateway", + ); + } + this.workerGateway = new WorkerGateway( + this.queue, + this.config.mcp.publicGatewayUrl, + this.sessionManager, + this.mcpConfigService, + this.instructionService, + this.mcpProxy, + this.providerCatalogService, + this.agentSettingsStore, + ); + logger.info("Worker gateway initialized"); + + // Discover OAuth capabilities for all MCP servers + logger.info("Discovering OAuth capabilities for MCP servers..."); + await this.mcpConfigService.enrichWithDiscovery(); + logger.info("MCP OAuth discovery completed"); + + // Register MCP OAuth module + this.mcpOAuthModule = new McpOAuthModule( + this.mcpConfigService, + mcpCredentialStore, + mcpOAuthStateStore, + mcpInputStore, + this.config.mcp.publicGatewayUrl, + this.config.mcp.callbackUrl, + ); + moduleRegistry.register(this.mcpOAuthModule); + logger.info("MCP OAuth module registered"); + + // Discover and initialize all available modules + await moduleRegistry.registerAvailableModules(); + await moduleRegistry.initAll(); + logger.info("Modules initialized"); + } + + // ============================================================================ + // 4b. Integration Services Initialization + // ============================================================================ + + private async initializeIntegrationServices(): Promise { + if (!this.queue) { + throw new Error("Queue must be initialized before integration services"); + } + + if (!this.systemSkillsService) { + logger.info( + "No SystemSkillsService available, integration services disabled", + ); + return; + } + + // Check if there are any integrations configured + const integrationConfigs = + await this.systemSkillsService.getAllIntegrationConfigs(); + if (Object.keys(integrationConfigs).length === 0) { + logger.info( + "No integrations found in system skills, integration services disabled", + ); + return; + } + + const redisClient = this.queue.getRedisClient(); + + if (!this.integrationConfigService) { + this.integrationConfigService = new IntegrationConfigService( + this.systemSkillsService, + this.agentSettingsStore, + ); + } + this.integrationCredentialStore = new IntegrationCredentialStore( + redisClient, + ); + + // Reuse MCP OAuth state store for integration OAuth flows + const mcpOAuthStateStore = createMcpOAuthStateStore(redisClient); + + const callbackUrl = `${this.config.mcp.publicGatewayUrl}/api/v1/auth/integration/callback`; + + if (!this.authSessionStore) { + throw new Error( + "Auth session store must be initialized before integration services", + ); + } + + this.integrationOAuthModule = new IntegrationOAuthModule( + this.integrationConfigService, + this.integrationCredentialStore, + mcpOAuthStateStore, + this.authSessionStore, + this.config.mcp.publicGatewayUrl, + callbackUrl, + this.queue, + ); + + logger.info( + `✅ Integration services initialized (${Object.keys(integrationConfigs).length} integration(s))`, + ); + } + + // ============================================================================ + // 7. Command Registry Initialization + // ============================================================================ + + private initializeCommandRegistry(): void { + if (!this.agentSettingsStore) { + throw new Error( + "Agent settings store must be initialized before command registry", + ); + } + + this.commandRegistry = new CommandRegistry(); + registerBuiltInCommands(this.commandRegistry, { + agentSettingsStore: this.agentSettingsStore, + sessionStore: this.getAuthSessionStore(), + }); + logger.info("✅ Command registry initialized with built-in commands"); + } + + // ============================================================================ + // Shutdown + // ============================================================================ + + async shutdown(): Promise { + logger.info("Shutting down core services..."); + + if (this.tokenRefreshJob) { + this.tokenRefreshJob.stop(); + } + + if (this.queueProducer) { + await this.queueProducer.stop(); + } + + if (this.workerGateway) { + this.workerGateway.shutdown(); + logger.info("Worker gateway shutdown complete"); + } + + if (this.queue) { + await this.queue.stop(); + } + + logger.info("✅ Core services shutdown complete"); + } + + // ============================================================================ + // Service Accessors (implements ICoreServices interface) + // ============================================================================ + + getQueue(): IMessageQueue { + if (!this.queue) throw new Error("Queue not initialized"); + return this.queue; + } + + getQueueProducer(): QueueProducer { + if (!this.queueProducer) throw new Error("Queue producer not initialized"); + return this.queueProducer; + } + + getSecretProxy(): SecretProxy | undefined { + return this.secretProxy; + } + + getWorkerGateway(): WorkerGateway | undefined { + return this.workerGateway; + } + + getMcpProxy(): McpProxy | undefined { + return this.mcpProxy; + } + + getMcpConfigService(): McpConfigService | undefined { + return this.mcpConfigService; + } + + getClaudeModelPreferenceStore(): ClaudeModelPreferenceStore | undefined { + return this.claudeModelPreferenceStore; + } + + getClaudeOAuthStateStore(): ClaudeOAuthStateStore | undefined { + return this.claudeOAuthStateStore; + } + + getPublicGatewayUrl(): string { + return this.config.mcp.publicGatewayUrl; + } + + getSessionManager(): SessionManager { + if (!this.sessionManager) + throw new Error("Session manager not initialized"); + return this.sessionManager; + } + + getInstructionService(): InstructionService | undefined { + return this.instructionService; + } + + getInteractionService(): InteractionService { + if (!this.interactionService) + throw new Error("Interaction service not initialized"); + return this.interactionService; + } + + getMcpOAuthModule(): McpOAuthModule | undefined { + return this.mcpOAuthModule; + } + + getAgentSettingsStore(): AgentSettingsStore { + if (!this.agentSettingsStore) + throw new Error("Agent settings store not initialized"); + return this.agentSettingsStore; + } + + getChannelBindingService(): ChannelBindingService { + if (!this.channelBindingService) + throw new Error("Channel binding service not initialized"); + return this.channelBindingService; + } + + getScheduledWakeupService(): ScheduledWakeupService | undefined { + return this.scheduledWakeupService; + } + + getTranscriptionService(): TranscriptionService | undefined { + return this.transcriptionService; + } + + getUserAgentsStore(): UserAgentsStore { + if (!this.userAgentsStore) + throw new Error("User agents store not initialized"); + return this.userAgentsStore; + } + + getAgentMetadataStore(): AgentMetadataStore { + if (!this.agentMetadataStore) + throw new Error("Agent metadata store not initialized"); + return this.agentMetadataStore; + } + + getAdminStatusCache(): AdminStatusCache { + if (!this.adminStatusCache) + throw new Error("Admin status cache not initialized"); + return this.adminStatusCache; + } + + getCommandRegistry(): CommandRegistry { + if (!this.commandRegistry) + throw new Error("Command registry not initialized"); + return this.commandRegistry; + } + + getProviderCatalogService(): ProviderCatalogService { + if (!this.providerCatalogService) + throw new Error("Provider catalog service not initialized"); + return this.providerCatalogService; + } + + getAuthProfilesManager(): AuthProfilesManager | undefined { + return this.authProfilesManager; + } + + getGrantStore(): GrantStore | undefined { + return this.grantStore; + } + + getSystemSkillsService(): SystemSkillsService | undefined { + return this.systemSkillsService; + } + + getIntegrationConfigService(): IntegrationConfigService | undefined { + return this.integrationConfigService; + } + + getIntegrationCredentialStore(): IntegrationCredentialStore | undefined { + return this.integrationCredentialStore; + } + + getIntegrationOAuthModule(): IntegrationOAuthModule | undefined { + return this.integrationOAuthModule; + } + + getAuthSessionStore(): AuthSessionStore { + if (!this.authSessionStore) + throw new Error("Auth session store not initialized"); + return this.authSessionStore; + } + + getSettingsOAuthProvider(): SettingsOAuthProvider | undefined { + return this.settingsOAuthProvider; + } + + getOAuthIdentityStore(): OAuthIdentityStore | undefined { + return this.oauthIdentityStore; + } } diff --git a/packages/gateway/src/slack/events/actions.ts b/packages/gateway/src/slack/events/actions.ts index ecd53336e..589a136af 100644 --- a/packages/gateway/src/slack/events/actions.ts +++ b/packages/gateway/src/slack/events/actions.ts @@ -4,12 +4,13 @@ const logger = createLogger("dispatcher"); import type { AnyBlock } from "@slack/types"; import type { WebClient } from "@slack/web-api"; +import { buildSessionUrl } from "../../auth/settings/session-store"; import { - buildSettingsUrl, - formatSettingsTokenTtl, - generateSettingsToken, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, } from "../../auth/settings/token-service"; import type { DispatcherModuleSource } from "../../modules/module-system"; +import { getSessionStore } from "../../routes/public/settings-auth"; import { resolveSpace } from "../../spaces"; import type { SlackActionBody, SlackContext } from "../types"; import type { MessageHandler } from "./messages"; @@ -23,86 +24,86 @@ import type { MessageHandler } from "./messages"; * Sends the code content back to Claude for execution */ async function handleExecutableCodeBlock( - actionId: string, - userId: string, - channelId: string, - messageTs: string, - body: SlackActionBody, - client: WebClient, - handleUserRequestFn: ( - context: SlackContext, - userInput: string, - client: WebClient - ) => Promise + actionId: string, + userId: string, + channelId: string, + messageTs: string, + body: SlackActionBody, + client: WebClient, + handleUserRequestFn: ( + context: SlackContext, + userInput: string, + client: WebClient, + ) => Promise, ): Promise { - logger.info(`Handling executable code block: ${actionId}`); - - try { - // Extract the code from the button's value - const bodyWithActions = body as { - actions?: Array<{ value?: string; text?: { text?: string } }>; - }; - const action = bodyWithActions.actions?.[0]; - if (!action?.value) { - throw new Error("No code content found in button"); - } - - const codeContent = action.value; - const language = actionId.split("_")[0]; // Extract language from action_id - const buttonText = action.text?.text || `Run ${language}`; - - // Post the code execution request as a user message - const formattedInput = `> 🚀 *Executed "${buttonText}" button*\n\n\`\`\`${language}\n${codeContent}\n\`\`\``; - - // Get the actual thread_ts from the message (messageTs is where button was clicked) - // If message has thread_ts, use it; otherwise this IS the thread root - const bodyWithMessage = body as { message?: { thread_ts?: string } }; - const actualThreadTs = bodyWithMessage.message?.thread_ts || messageTs; - - const inputMessage = await client.chat.postMessage({ - channel: channelId, - thread_ts: actualThreadTs, - text: formattedInput, - blocks: [ - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `<@${userId}> executed "${buttonText}" button`, - } as any, - ], - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `\`\`\`${language}\n${codeContent}\n\`\`\``, - }, - }, - ], - }); - - const context = { - channelId, - userId, - userDisplayName: (body as any).user?.name || "Unknown User", - teamId: (body as any).team?.id || "", - messageTs: inputMessage.ts as string, - threadTs: actualThreadTs, - text: formattedInput, - }; - - await handleUserRequestFn(context, formattedInput, client); - } catch (error) { - logger.error(`Failed to handle executable code block ${actionId}:`, error); - const actualThreadTs = (body as any)?.message?.thread_ts || messageTs; - await client.chat.postMessage({ - channel: channelId, - thread_ts: actualThreadTs, - text: `❌ Failed to execute code: ${error instanceof Error ? error.message : "Unknown error"}`, - }); - } + logger.info(`Handling executable code block: ${actionId}`); + + try { + // Extract the code from the button's value + const bodyWithActions = body as { + actions?: Array<{ value?: string; text?: { text?: string } }>; + }; + const action = bodyWithActions.actions?.[0]; + if (!action?.value) { + throw new Error("No code content found in button"); + } + + const codeContent = action.value; + const language = actionId.split("_")[0]; // Extract language from action_id + const buttonText = action.text?.text || `Run ${language}`; + + // Post the code execution request as a user message + const formattedInput = `> 🚀 *Executed "${buttonText}" button*\n\n\`\`\`${language}\n${codeContent}\n\`\`\``; + + // Get the actual thread_ts from the message (messageTs is where button was clicked) + // If message has thread_ts, use it; otherwise this IS the thread root + const bodyWithMessage = body as { message?: { thread_ts?: string } }; + const actualThreadTs = bodyWithMessage.message?.thread_ts || messageTs; + + const inputMessage = await client.chat.postMessage({ + channel: channelId, + thread_ts: actualThreadTs, + text: formattedInput, + blocks: [ + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<@${userId}> executed "${buttonText}" button`, + } as any, + ], + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`${language}\n${codeContent}\n\`\`\``, + }, + }, + ], + }); + + const context = { + channelId, + userId, + userDisplayName: (body as any).user?.name || "Unknown User", + teamId: (body as any).team?.id || "", + messageTs: inputMessage.ts as string, + threadTs: actualThreadTs, + text: formattedInput, + }; + + await handleUserRequestFn(context, formattedInput, client); + } catch (error) { + logger.error(`Failed to handle executable code block ${actionId}:`, error); + const actualThreadTs = (body as any)?.message?.thread_ts || messageTs; + await client.chat.postMessage({ + channel: channelId, + thread_ts: actualThreadTs, + text: `❌ Failed to execute code: ${error instanceof Error ? error.message : "Unknown error"}`, + }); + } } /** @@ -110,337 +111,339 @@ async function handleExecutableCodeBlock( * Opens a modal with the blockkit form content */ async function handleBlockkitForm( - actionId: string, - channelId: string, - messageTs: string, - body: SlackActionBody, - client: WebClient + actionId: string, + channelId: string, + messageTs: string, + body: SlackActionBody, + client: WebClient, ): Promise { - logger.info(`Handling blockkit form: ${actionId}`); - - try { - // Extract the blocks from the button's value - const action = (body as any).actions?.[0]; - if (!action?.value) { - logger.error(`No form data found in button for action ${actionId}`); - throw new Error("No form data found in button"); - } - - let formData; - try { - formData = JSON.parse(action.value); - } catch (parseError) { - logger.error( - `Failed to parse form data for action ${actionId}:`, - parseError - ); - logger.error(`Raw value: ${action.value}`); - throw new Error(`Invalid JSON in form data: ${parseError}`); - } - - const blocks = formData.blocks || []; - - if (blocks.length === 0) { - logger.error(`No blocks found in form data for action ${actionId}`); - throw new Error("No blocks found in form data"); - } - - // Check if trigger_id exists - if (!body.trigger_id) { - logger.error(`No trigger_id in body for action ${actionId}`); - throw new Error("No trigger_id available - cannot open modal"); - } - - // Get the actual thread_ts from the message (messageTs is where button was clicked) - // If message has thread_ts, use it; otherwise this IS the thread root - const actualThreadTs = (body as any).message?.thread_ts || messageTs; - - logger.info( - `Opening modal for action ${actionId}, trigger_id: ${body.trigger_id}` - ); - - // Create modal with the blockkit form - const modalResult = await client.views.open({ - trigger_id: body.trigger_id, - view: { - type: "modal", - callback_id: "blockkit_form_modal", - private_metadata: JSON.stringify({ - channel_id: channelId, - thread_ts: actualThreadTs, - action_id: actionId, - button_text: action.text?.text || "Form", - }), - title: { type: "plain_text", text: action.text?.text || "Form" }, - submit: { type: "plain_text", text: "Submit" }, - close: { type: "plain_text", text: "Cancel" }, - blocks: blocks, - } as any, - }); - - logger.info( - `Modal opened successfully for action ${actionId}, ok: ${modalResult.ok}` - ); - } catch (error: unknown) { - const err = error as { - message?: string; - data?: unknown; - code?: string; - stack?: string; - }; - logger.error(`Failed to handle blockkit form ${actionId}:`, { - error: err.message, - data: err.data, - code: err.code, - stack: err.stack, - }); - - // Show the raw Block Kit content for troubleshooting - const rawBlocksJson = JSON.stringify(body, null, 2); - const truncatedBlocks = - rawBlocksJson.length > 2500 - ? `${rawBlocksJson.substring(0, 2500)}\n...[truncated]` - : rawBlocksJson; - - const bodyWithMessage = body as { message?: { thread_ts?: string } }; - const actualThreadTs = bodyWithMessage?.message?.thread_ts || messageTs; - await client.chat.postMessage({ - channel: channelId, - thread_ts: actualThreadTs, - text: `❌ Failed to open form: ${error instanceof Error ? error.message : "Unknown error"}\n\nRaw Block Kit (truncated):\n\`\`\`json\n${truncatedBlocks}\n\`\`\`\n\nTip: Some blocks are not modal-compatible.`, - }); - } + logger.info(`Handling blockkit form: ${actionId}`); + + try { + // Extract the blocks from the button's value + const action = (body as any).actions?.[0]; + if (!action?.value) { + logger.error(`No form data found in button for action ${actionId}`); + throw new Error("No form data found in button"); + } + + let formData; + try { + formData = JSON.parse(action.value); + } catch (parseError) { + logger.error( + `Failed to parse form data for action ${actionId}:`, + parseError, + ); + logger.error(`Raw value: ${action.value}`); + throw new Error(`Invalid JSON in form data: ${parseError}`); + } + + const blocks = formData.blocks || []; + + if (blocks.length === 0) { + logger.error(`No blocks found in form data for action ${actionId}`); + throw new Error("No blocks found in form data"); + } + + // Check if trigger_id exists + if (!body.trigger_id) { + logger.error(`No trigger_id in body for action ${actionId}`); + throw new Error("No trigger_id available - cannot open modal"); + } + + // Get the actual thread_ts from the message (messageTs is where button was clicked) + // If message has thread_ts, use it; otherwise this IS the thread root + const actualThreadTs = (body as any).message?.thread_ts || messageTs; + + logger.info( + `Opening modal for action ${actionId}, trigger_id: ${body.trigger_id}`, + ); + + // Create modal with the blockkit form + const modalResult = await client.views.open({ + trigger_id: body.trigger_id, + view: { + type: "modal", + callback_id: "blockkit_form_modal", + private_metadata: JSON.stringify({ + channel_id: channelId, + thread_ts: actualThreadTs, + action_id: actionId, + button_text: action.text?.text || "Form", + }), + title: { type: "plain_text", text: action.text?.text || "Form" }, + submit: { type: "plain_text", text: "Submit" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: blocks, + } as any, + }); + + logger.info( + `Modal opened successfully for action ${actionId}, ok: ${modalResult.ok}`, + ); + } catch (error: unknown) { + const err = error as { + message?: string; + data?: unknown; + code?: string; + stack?: string; + }; + logger.error(`Failed to handle blockkit form ${actionId}:`, { + error: err.message, + data: err.data, + code: err.code, + stack: err.stack, + }); + + // Show the raw Block Kit content for troubleshooting + const rawBlocksJson = JSON.stringify(body, null, 2); + const truncatedBlocks = + rawBlocksJson.length > 2500 + ? `${rawBlocksJson.substring(0, 2500)}\n...[truncated]` + : rawBlocksJson; + + const bodyWithMessage = body as { message?: { thread_ts?: string } }; + const actualThreadTs = bodyWithMessage?.message?.thread_ts || messageTs; + await client.chat.postMessage({ + channel: channelId, + thread_ts: actualThreadTs, + text: `❌ Failed to open form: ${error instanceof Error ? error.message : "Unknown error"}\n\nRaw Block Kit (truncated):\n\`\`\`json\n${truncatedBlocks}\n\`\`\`\n\nTip: Some blocks are not modal-compatible.`, + }); + } } export class ActionHandler { - constructor( - private messageHandler: MessageHandler, - private moduleRegistry: DispatcherModuleSource - ) {} - - /** - * Handle block action events - */ - async handleBlockAction( - actionId: string, - userId: string, - channelId: string, - messageTs: string, - body: SlackActionBody, - client: WebClient - ): Promise { - logger.info(`Handling block action: ${actionId}`); - - // Interaction handlers (radio_, submit_, section_, next_) are registered - // via Slack Bolt app.action() in interactions.ts - // Don't handle them here - let them pass through to Bolt handlers - if (actionId.match(/^(radio|submit|section|next)_/)) { - logger.debug( - `Skipping ${actionId} - handled by Bolt interaction handlers` - ); - return; - } - - // Try to handle action through modules first - let handled = false; - const dispatcherModules = this.moduleRegistry.getDispatcherModules(); - - // Resolve agentId from context for module actions - const isDirectMessage = channelId.startsWith("D"); - const { agentId } = resolveSpace({ - platform: "slack", - userId, - channelId, - isGroup: !isDirectMessage, - }); - - for (const module of dispatcherModules) { - if (module.handleAction) { - const moduleHandled = await module.handleAction( - actionId, - userId, - agentId, - { - channelId, - client, - body, - agentId, - updateAppHome: this.updateAppHome.bind(this), - messageHandler: this.messageHandler, - } - ); - if (moduleHandled) { - handled = true; - break; - } - } - } - - if (!handled) { - // Handle Settings button from home tab - if (actionId === "open_settings") { - await this.handleOpenSettings(userId, client); - } - // Handle blockkit form button clicks - else if (actionId.startsWith("blockkit_form_")) { - await handleBlockkitForm(actionId, channelId, messageTs, body, client); - } - // Handle executable code block buttons - else if ( - actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) - ) { - await handleExecutableCodeBlock( - actionId, - userId, - channelId, - messageTs, - body, - client, - (context: SlackContext, userRequest: string, client: WebClient) => - this.messageHandler.handleUserRequest(context, userRequest, client) - ); - } else { - logger.info( - `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}` - ); - } - } - } - - /** - * Handle "Open Settings" button click from home tab - * Generates a settings link and sends it via DM - */ - private async handleOpenSettings( - userId: string, - client: WebClient - ): Promise { - logger.info(`Generating settings link for user: ${userId}`); - - try { - // Resolve agentId for user's personal space (DM context) - const { agentId } = resolveSpace({ - platform: "slack", - userId, - channelId: userId, // Use userId as channelId for DM-like context - isGroup: false, - }); - - // Generate settings token (configured TTL, default 1 hour) - const token = generateSettingsToken(agentId, userId, "slack"); - const settingsUrl = buildSettingsUrl(token); - const ttlLabel = formatSettingsTokenTtl(); - - // Send DM with settings link - await client.chat.postMessage({ - channel: userId, // DM to user - text: `Here's your settings link (expires in ${ttlLabel}):\n${settingsUrl}`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "*Your Settings Link*\nConfigure skills, MCP servers, environment variables, and more.", - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Open Settings" }, - url: settingsUrl, - style: "primary", - }, - ], - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `_This link expires in ${ttlLabel}. Click the button above or copy the URL._`, - } as any, - ], - }, - ], - }); - - logger.info(`Settings link sent to user ${userId}`); - } catch (error) { - logger.error(`Failed to send settings link to user ${userId}:`, error); - - // Try to send error message to user - try { - await client.chat.postMessage({ - channel: userId, - text: "Sorry, I couldn't generate your settings link. Please try again.", - }); - } catch { - // Ignore if DM fails - } - } - } - - /** - * Update App Home tab - simple welcome with Settings button - */ - async updateAppHome(userId: string, client: WebClient): Promise { - logger.info(`Updating app home for user: ${userId}`); - - try { - const blocks: AnyBlock[] = [ - { - type: "section", - text: { - type: "mrkdwn", - text: "*Welcome to Lobu!* 👋\n\nYour AI coding assistant is ready to help.", - }, - }, - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "*Configure Your Agent*\nSet up skills, MCP servers, environment variables, and more.", - }, - accessory: { - type: "button", - text: { type: "plain_text", text: "Open Settings" }, - action_id: "open_settings", - style: "primary", - }, - }, - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: - "*💡 Quick Tips:*\n" + - "• Mention me in any channel or DM me directly\n" + - "• Ask questions about code, create features, or fix bugs\n" + - "• Use the Settings button above to configure skills and integrations", - }, - }, - ]; - - // Update the app home view - await client.views.publish({ - user_id: userId, - view: { - type: "home", - blocks, - } as any, - }); - - logger.info(`App home updated for user ${userId}`); - } catch (error) { - logger.error(`Failed to update app home for user ${userId}:`, error); - } - } + constructor( + private messageHandler: MessageHandler, + private moduleRegistry: DispatcherModuleSource, + ) {} + + /** + * Handle block action events + */ + async handleBlockAction( + actionId: string, + userId: string, + channelId: string, + messageTs: string, + body: SlackActionBody, + client: WebClient, + ): Promise { + logger.info(`Handling block action: ${actionId}`); + + // Interaction handlers (radio_, submit_, section_, next_) are registered + // via Slack Bolt app.action() in interactions.ts + // Don't handle them here - let them pass through to Bolt handlers + if (actionId.match(/^(radio|submit|section|next)_/)) { + logger.debug( + `Skipping ${actionId} - handled by Bolt interaction handlers`, + ); + return; + } + + // Try to handle action through modules first + let handled = false; + const dispatcherModules = this.moduleRegistry.getDispatcherModules(); + + // Resolve agentId from context for module actions + const isDirectMessage = channelId.startsWith("D"); + const { agentId } = resolveSpace({ + platform: "slack", + userId, + channelId, + isGroup: !isDirectMessage, + }); + + for (const module of dispatcherModules) { + if (module.handleAction) { + const moduleHandled = await module.handleAction( + actionId, + userId, + agentId, + { + channelId, + client, + body, + agentId, + updateAppHome: this.updateAppHome.bind(this), + messageHandler: this.messageHandler, + }, + ); + if (moduleHandled) { + handled = true; + break; + } + } + } + + if (!handled) { + // Handle Settings button from home tab + if (actionId === "open_settings") { + await this.handleOpenSettings(userId, client); + } + // Handle blockkit form button clicks + else if (actionId.startsWith("blockkit_form_")) { + await handleBlockkitForm(actionId, channelId, messageTs, body, client); + } + // Handle executable code block buttons + else if ( + actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) + ) { + await handleExecutableCodeBlock( + actionId, + userId, + channelId, + messageTs, + body, + client, + (context: SlackContext, userRequest: string, client: WebClient) => + this.messageHandler.handleUserRequest(context, userRequest, client), + ); + } else { + logger.info( + `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}`, + ); + } + } + } + + /** + * Handle "Open Settings" button click from home tab + * Generates a settings link and sends it via DM + */ + private async handleOpenSettings( + userId: string, + client: WebClient, + ): Promise { + logger.info(`Generating settings link for user: ${userId}`); + + try { + // Resolve agentId for user's personal space (DM context) + const { agentId } = resolveSpace({ + platform: "slack", + userId, + channelId: userId, // Use userId as channelId for DM-like context + isGroup: false, + }); + + const { sessionId } = await getSessionStore().createSession( + { userId, platform: "slack", agentId }, + getSettingsTokenTtlMs(), + ); + const settingsUrl = buildSessionUrl(sessionId); + const ttlLabel = formatSettingsTokenTtl(); + + // Send DM with settings link + await client.chat.postMessage({ + channel: userId, // DM to user + text: `Here's your settings link (expires in ${ttlLabel}):\n${settingsUrl}`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "*Your Settings Link*\nConfigure skills, MCP servers, environment variables, and more.", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Open Settings" }, + url: settingsUrl, + style: "primary", + }, + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `_This link expires in ${ttlLabel}. Click the button above or copy the URL._`, + } as any, + ], + }, + ], + }); + + logger.info(`Settings link sent to user ${userId}`); + } catch (error) { + logger.error(`Failed to send settings link to user ${userId}:`, error); + + // Try to send error message to user + try { + await client.chat.postMessage({ + channel: userId, + text: "Sorry, I couldn't generate your settings link. Please try again.", + }); + } catch { + // Ignore if DM fails + } + } + } + + /** + * Update App Home tab - simple welcome with Settings button + */ + async updateAppHome(userId: string, client: WebClient): Promise { + logger.info(`Updating app home for user: ${userId}`); + + try { + const blocks: AnyBlock[] = [ + { + type: "section", + text: { + type: "mrkdwn", + text: "*Welcome to Lobu!* 👋\n\nYour AI coding assistant is ready to help.", + }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*Configure Your Agent*\nSet up skills, MCP servers, environment variables, and more.", + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "Open Settings" }, + action_id: "open_settings", + style: "primary", + }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: + "*💡 Quick Tips:*\n" + + "• Mention me in any channel or DM me directly\n" + + "• Ask questions about code, create features, or fix bugs\n" + + "• Use the Settings button above to configure skills and integrations", + }, + }, + ]; + + // Update the app home view + await client.views.publish({ + user_id: userId, + view: { + type: "home", + blocks, + } as any, + }); + + logger.info(`App home updated for user ${userId}`); + } catch (error) { + logger.error(`Failed to update app home for user ${userId}:`, error); + } + } } diff --git a/packages/gateway/src/slack/events/messages.ts b/packages/gateway/src/slack/events/messages.ts index adbd2f89a..ef723ab5e 100644 --- a/packages/gateway/src/slack/events/messages.ts +++ b/packages/gateway/src/slack/events/messages.ts @@ -3,19 +3,18 @@ import type { WebClient } from "@slack/web-api"; import type { AdminStatusCache } from "../../auth/admin-status-cache"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; import type { AgentSettingsStore } from "../../auth/settings"; -import { - buildSettingsUrl, - generateChannelSettingsToken, -} from "../../auth/settings/token-service"; +import { buildSessionUrl } from "../../auth/settings/session-store"; +import { getSettingsTokenTtlMs } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChannelBindingService } from "../../channels"; import type { CommandDispatcher } from "../../commands/command-dispatcher"; import { createSlackThreadReply } from "../../commands/command-reply-adapters"; import type { QueueProducer } from "../../infrastructure/queue/queue-producer"; +import { getSessionStore } from "../../routes/public/settings-auth"; import { - buildMessagePayload, - resolveAgentId, - resolveAgentOptions, + buildMessagePayload, + resolveAgentId, + resolveAgentOptions, } from "../../services/platform-helpers"; import type { TranscriptionService } from "../../services/transcription-service"; import type { ISessionManager, ThreadSession } from "../../session"; @@ -26,792 +25,795 @@ import type { SlackContext, SlackMessageEvent } from "../types"; const logger = createLogger("dispatcher"); export class MessageHandler { - private readonly SESSION_TTL = DEFAULTS.SESSION_TTL_MS; - private channelBindingService?: ChannelBindingService; - private agentSettingsStore?: AgentSettingsStore; - private transcriptionService?: TranscriptionService; - private userAgentsStore?: UserAgentsStore; - private agentMetadataStore?: AgentMetadataStore; - private adminStatusCache?: AdminStatusCache; - private commandDispatcher?: CommandDispatcher; - - constructor( - private queueProducer: QueueProducer, - private config: MessageHandlerConfig, - private sessionManager: ISessionManager, - private slackClient: WebClient - ) {} - - /** - * Set the channel binding service (optional) - */ - setChannelBindingService(service: ChannelBindingService): void { - this.channelBindingService = service; - } - - /** - * Set the agent settings store (optional) - */ - setAgentSettingsStore(store: AgentSettingsStore): void { - this.agentSettingsStore = store; - } - - /** - * Set the transcription service for voice/audio processing (optional) - */ - setTranscriptionService(service: TranscriptionService): void { - this.transcriptionService = service; - } - - /** - * Set user agents store for agent configuration flow - */ - setUserAgentsStore(store: UserAgentsStore): void { - this.userAgentsStore = store; - } - - /** - * Set agent metadata store for agent configuration flow - */ - setAgentMetadataStore(store: AgentMetadataStore): void { - this.agentMetadataStore = store; - } - - /** - * Set admin status cache for permission checks - */ - setAdminStatusCache(cache: AdminStatusCache): void { - this.adminStatusCache = cache; - } - - setCommandDispatcher(dispatcher: CommandDispatcher): void { - this.commandDispatcher = dispatcher; - } - - /** - * Transcribe audio files from Slack message. - * Returns the original message with transcriptions prepended. - */ - private async transcribeAudioFiles( - userRequest: string, - files: any[] | undefined, - slackToken?: string - ): Promise { - if (!files?.length || !this.transcriptionService) { - return userRequest; - } - - // Filter for audio files - const audioFiles = files.filter((f) => { - const mimetype = f.mimetype?.toLowerCase() || ""; - const filetype = f.filetype?.toLowerCase() || ""; - return ( - mimetype.startsWith("audio/") || - mimetype === "application/ogg" || - ["mp3", "m4a", "wav", "ogg", "opus", "webm", "aac"].includes(filetype) - ); - }); - - if (audioFiles.length === 0) { - return userRequest; - } - - logger.info( - { audioFileCount: audioFiles.length }, - "Attempting to transcribe Slack audio files" - ); - - const transcriptions: string[] = []; - - for (const audioFile of audioFiles) { - try { - // Download the file from Slack - const downloadUrl = - audioFile.url_private_download || audioFile.url_private; - if (!downloadUrl) { - logger.warn( - { fileId: audioFile.id }, - "No download URL for audio file" - ); - continue; - } - - const token = - slackToken || this.config.slack.token || process.env.SLACK_BOT_TOKEN; - const response = await fetch(downloadUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - logger.warn( - { fileId: audioFile.id, status: response.status }, - "Failed to download Slack audio file" - ); - continue; - } - - const buffer = Buffer.from(await response.arrayBuffer()); - const mimetype = audioFile.mimetype || "audio/mpeg"; - const filename = - audioFile.name || `audio.${audioFile.filetype || "mp3"}`; - - const result = await this.transcriptionService.transcribe( - buffer, - filename, - mimetype - ); - - if ("text" in result) { - // Success case - transcriptions.push(`[Voice message]: ${result.text}`); - logger.info( - { fileId: audioFile.id, textLength: result.text.length }, - "Audio transcription successful" - ); - } else if ( - result.error?.includes("No transcription provider configured") - ) { - logger.info("Transcription service not configured - skipping audio"); - break; // No point trying more files - } else { - logger.warn( - { fileId: audioFile.id, error: result.error }, - "Audio transcription failed" - ); - } - } catch (error) { - logger.error( - { fileId: audioFile.id, error: String(error) }, - "Error transcribing audio file" - ); - } - } - - if (transcriptions.length === 0) { - return userRequest; - } - - // Prepend transcriptions to the message - const transcriptionPrefix = transcriptions.join("\n\n"); - if (!userRequest.trim() || userRequest === "[Audio message]") { - return transcriptionPrefix; - } - return `${transcriptionPrefix}\n\n${userRequest}`; - } - - /** - * Get agent options with settings applied - */ - private getAgentOptionsWithSettings( - agentId: string - ): Promise> { - const baseOptions = { - ...this.config.agentOptions, - timeoutMinutes: this.config.sessionTimeoutMinutes.toString(), - }; - return resolveAgentOptions(agentId, baseOptions, this.agentSettingsStore); - } - - /** - * Get bot ID from configuration - */ - private getBotId(): string { - return this.config.slack.botId || "default-slack-bot"; - } - - /** - * Set thread status indicator - */ - private async setThreadStatus( - channelId: string, - threadTs: string, - status: string - ): Promise { - try { - logger.info( - `Setting thread status "${status}" for channel ${channelId}, thread ${threadTs}` - ); - await this.slackClient.apiCall("assistant.threads.setStatus", { - channel_id: channelId, - thread_ts: threadTs, - status, - loading_messages: [ - "warming up...", - "getting ready...", - "thinking about it...", - "on it...", - "loading...", - "waking up...", - "brewing some thoughts...", - "putting on thinking cap...", - ], - }); - logger.info(`Successfully set thread status "${status}"`); - } catch (error) { - // Non-critical - just log - logger.warn(`Failed to set thread status: ${error}`); - } - } - - /** - * Handle user request by routing to appropriate queue - */ - async handleUserRequest( - context: SlackContext, - userRequest: string, - client: WebClient, - files?: any[] - ): Promise { - const requestStartTime = Date.now(); - logger.info( - `[TIMING] handleUserRequest started at: ${new Date(requestStartTime).toISOString()}` - ); - logger.info( - `📨 Handling request from user ${context.userId} in thread ${context.threadTs || context.messageTs}` - ); - - // Transcribe audio files if present - const processedRequest = await this.transcribeAudioFiles( - userRequest, - files, - client.token - ); - - // CRITICAL: Always use thread_ts for thread identification - // For root messages: thread_ts is undefined, so we use message_ts - // For replies in thread: thread_ts points to the root message - // This ensures all messages in a thread share the same worker - const normalizedThreadTs = context.threadTs || context.messageTs; - - // Log for debugging thread routing - logger.info( - `Thread routing - messageTs: ${context.messageTs}, threadTs: ${context.threadTs}, normalizedThreadTs: ${normalizedThreadTs}` - ); - - // Generate session key with normalized threadTs - use thread creator as userId for consistency - const threadCreatorSessionKey = generateSessionKey({ - platform: "slack", - channelId: context.channelId, - userId: context.userId, - conversationId: normalizedThreadTs, - messageId: context.messageTs, - }); - - // Check if this is a Direct Message channel (DMs start with 'D') - const isDirectMessage = context.channelId.startsWith("D"); - - // Handle slash commands via shared dispatcher before normal message routing - if (this.commandDispatcher) { - const handled = await this.commandDispatcher.tryHandleSlashText( - userRequest, - { - platform: "slack", - userId: context.userId, - channelId: context.channelId, - teamId: context.teamId, - isGroup: !isDirectMessage, - conversationId: normalizedThreadTs, - reply: createSlackThreadReply( - client, - context.channelId, - normalizedThreadTs - ), - } - ); - - if (handled) return; - } - - // Resolve agent ID from channel binding or space fallback - const resolved = await resolveAgentId({ - platform: "slack", - userId: context.userId, - channelId: context.channelId, - isGroup: !isDirectMessage, - teamId: context.teamId, - channelBindingService: this.channelBindingService, - sendConfigPrompt: () => - this.sendConfigurationPrompt(context, client, isDirectMessage), - }); - if (resolved.promptSent) return; - const agentId = resolved.agentId; - - // Only check thread ownership for non-DM channels - if (!isDirectMessage) { - const ownershipCheck = await this.sessionManager.validateThreadOwnership( - context.channelId, - normalizedThreadTs, - context.userId - ); - - if (!ownershipCheck.allowed && ownershipCheck.owner) { - logger.warn( - `User ${context.userId} tried to interact with thread owned by ${ownershipCheck.owner}` - ); - - // Send ownership message - await client.chat.postMessage({ - channel: context.channelId, - thread_ts: normalizedThreadTs, - text: `This thread is owned by <@${ownershipCheck.owner}>. Only the thread creator can interact with the bot in this conversation.`, - mrkdwn: true, - }); - - return; - } - } else { - logger.info( - `Skipping thread ownership check for DM channel ${context.channelId}` - ); - } - - // Get existing session if any - const existingSession = await this.sessionManager.findSessionByThread( - context.channelId, - normalizedThreadTs - ); - - const sessionKey = threadCreatorSessionKey; - - logger.info( - `Handling request for session: ${sessionKey} (threadTs: ${normalizedThreadTs})` - ); - - // Check turn count to prevent infinite loops - const maxTurns = process.env.MAX_TURNS - ? parseInt(process.env.MAX_TURNS, 10) - : 50; - const currentTurnCount = (existingSession?.turnCount || 0) + 1; - - if (currentTurnCount > maxTurns) { - logger.warn( - `Thread ${normalizedThreadTs} exceeded MAX_TURNS (${maxTurns}). Preventing infinite loop.` - ); - await client.chat.postMessage({ - channel: context.channelId, - thread_ts: normalizedThreadTs, - text: `⚠️ This conversation has exceeded the maximum turn limit (${maxTurns} turns). Please start a new thread to continue.`, - }); - return; - } - - logger.info(`Turn count: ${currentTurnCount}/${maxTurns}`); - - try { - const conversationId = normalizedThreadTs; - - // Create thread session with turn count - const threadSession: ThreadSession = { - conversationId, - channelId: context.channelId, - userId: context.userId, - threadCreator: context.userId, // Store the thread creator - lastActivity: Date.now(), - createdAt: Date.now(), - turnCount: currentTurnCount, - }; - - await this.sessionManager.setSession(threadSession); - - // Determine if this is a new conversation - // A conversation is new only if this message is the ROOT of the thread (messageTs === threadTs) - // OR if there's no thread_ts at all (first message in a channel/DM) - const isNewConversation = - context.messageTs === normalizedThreadTs && !existingSession; - - // Fetch agent settings and merge with config defaults - const agentOptions = await this.getAgentOptionsWithSettings(agentId); - - if (isNewConversation) { - await this.sessionManager.setSession(threadSession); - } else { - await this.sessionManager.setSession(threadSession); - } - - const payload = buildMessagePayload({ - platform: "slack", - userId: context.userId, - botId: this.getBotId(), - conversationId, - teamId: context.teamId, - agentId, - messageId: context.messageTs, - messageText: processedRequest, - channelId: context.channelId, - platformMetadata: { - teamId: context.teamId, - userDisplayName: context.userDisplayName, - responseChannel: context.channelId, - responseId: context.messageTs, - originalMessageId: context.messageTs, - botResponseId: threadSession.botResponseId, - files: files || [], - }, - agentOptions, - }); - - const jobId = await this.queueProducer.enqueueMessage(payload); - - // Set status indicator - await this.setThreadStatus( - context.channelId, - conversationId, - "is scheduling.." - ); - - logger.info( - `Enqueued ${isNewConversation ? "direct message" : "thread message"} job ${jobId} for ${isNewConversation ? `session ${sessionKey}` : `conversation ${conversationId}`}` - ); - } catch (error) { - logger.error( - `Failed to handle request for session ${sessionKey}:`, - error - ); - - // Handle all errors the same way - let the worker decide what to show - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorMsg = `❌ *Error:* ${errorMessage || "Unknown error occurred"}`; - - // Post error message in thread - const threadTs = context.threadTs || context.messageTs; - await client.chat.postMessage({ - channel: context.channelId, - thread_ts: threadTs, - text: errorMsg, - mrkdwn: true, - }); - - // Clean up session - await this.sessionManager.deleteSession(sessionKey); - } - } - - /** - * Send a configuration prompt to the user when no agent is bound to a channel. - * Returns true if the prompt was sent (caller should stop processing). - * Returns false if the prompt could not be sent (caller should fallback). - */ - private async sendConfigurationPrompt( - context: SlackContext, - client: WebClient, - isDirectMessage: boolean - ): Promise { - // Need all stores to send a config prompt - if (!this.userAgentsStore || !this.agentMetadataStore) { - return false; - } - - try { - const token = generateChannelSettingsToken( - context.userId, - "slack", - context.channelId, - context.teamId - ); - - const configUrl = buildSettingsUrl(token); - const threadTs = context.threadTs || context.messageTs; - - if (isDirectMessage) { - // DM - reply directly in the conversation - await client.chat.postMessage({ - channel: context.channelId, - thread_ts: threadTs, - text: `Welcome! To get started, please configure which agent should handle this conversation.`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Welcome! To get started, please configure which agent should handle this conversation.`, - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Configure Agent" }, - url: configUrl, - style: "primary", - action_id: "configure_agent", - }, - ], - }, - ], - }); - } else { - // Group channel - check admin permissions - const canConfigure = await this.checkCanConfigure( - "slack", - context.channelId, - context.userId, - context.teamId - ); - - if (!canConfigure.allowed) { - await client.chat.postEphemeral({ - channel: context.channelId, - user: context.userId, - text: - canConfigure.reason || - "Only admins can configure the bot for this channel.", - }); - return true; - } - - // Slack chat.postMessage needs a channel ID. Open (or find) a DM channel first. - let dmChannelId: string | undefined; - try { - const opened = await client.conversations.open({ - users: context.userId, - return_im: true, - }); - dmChannelId = opened.channel?.id; - } catch (error) { - logger.warn("Failed to open DM channel for configuration link", { - error, - userId: context.userId, - }); - } - - if (dmChannelId) { - await client.chat.postMessage({ - channel: dmChannelId, - text: `Please configure which agent should handle messages in <#${context.channelId}>`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Please configure which agent should handle messages in <#${context.channelId}>`, - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Configure Agent" }, - url: configUrl, - style: "primary", - action_id: "configure_agent", - }, - ], - }, - ], - }); - } else { - // If DM can't be opened (scopes, Slack restrictions), send ephemeral with a button. - await client.chat.postEphemeral({ - channel: context.channelId, - user: context.userId, - text: `Configure which agent should handle messages in <#${context.channelId}>: ${configUrl}`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Configure which agent should handle messages in <#${context.channelId}>`, - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Configure Agent" }, - url: configUrl, - style: "primary", - action_id: "configure_agent", - }, - ], - }, - ], - }); - return true; - } - - // Ephemeral reply in the channel - await client.chat.postEphemeral({ - channel: context.channelId, - user: context.userId, - text: "I sent you a DM with a link to configure your agent for this channel.", - }); - } - - logger.info( - `Sent configuration prompt to user ${context.userId} for channel ${context.channelId}` - ); - return true; - } catch (error) { - logger.error("Failed to send configuration prompt", { error }); - return false; - } - } - - /** - * Check if a user can configure an agent for a channel. - * DMs always allow. Groups check admin status with fallback to first-user. - */ - private async checkCanConfigure( - platform: string, - channelId: string, - userId: string, - teamId?: string - ): Promise<{ allowed: boolean; reason?: string }> { - // Always allow in DMs - if (channelId.startsWith("D")) { - return { allowed: true }; - } - - // Check if already configured by someone else - if (this.channelBindingService) { - const existing = await this.channelBindingService.getBinding( - platform, - channelId, - teamId - ); - if (existing?.configuredBy && existing.configuredBy !== userId) { - // Already configured by someone else - check if current user is admin - const isAdmin = await this.isSlackWorkspaceAdmin(userId); - if (isAdmin !== true) { - return { - allowed: false, - reason: `This channel is already configured by another user. Only workspace admins can reconfigure.`, - }; - } - } - } - - // Try admin check - const isAdmin = await this.isSlackWorkspaceAdmin(userId); - - if (isAdmin === true) { - return { allowed: true }; - } - - if (isAdmin === false) { - return { - allowed: false, - reason: - "Only workspace admins can configure the bot for group channels.", - }; - } - - // isAdmin === null (API error) - fallback to first-user - if (this.channelBindingService) { - const existing = await this.channelBindingService.getBinding( - platform, - channelId, - teamId - ); - if (!existing) { - logger.info( - `Admin check failed for ${userId}, using first-user fallback` - ); - return { allowed: true }; - } - } - - return { allowed: true }; - } - - /** - * Check if a Slack user is a workspace admin. - * Returns true/false for definitive answer, null if API check failed. - */ - private async isSlackWorkspaceAdmin(userId: string): Promise { - // Check cache first - if (this.adminStatusCache) { - const cached = await this.adminStatusCache.getStatus( - "slack", - "workspace", - userId - ); - if (cached !== null) return cached; - } - - try { - const userInfo = await this.slackClient.users.info({ user: userId }); - const isAdmin = - userInfo.user?.is_admin || userInfo.user?.is_owner || false; - - // Cache the result - if (this.adminStatusCache) { - await this.adminStatusCache.setStatus( - "slack", - "workspace", - userId, - isAdmin - ); - } - - return isAdmin; - } catch (error) { - logger.warn(`Failed to check Slack admin status for ${userId}`, { - error, - }); - return null; - } - } - - /** - * Extract Slack context from event - */ - extractSlackContext( - event: SlackMessageEvent, - bodyTeamId?: string - ): SlackContext { - // Extract teamId from event.team (optional) or body.team_id (always present) - const teamId = event.team || bodyTeamId || ""; - - return { - channelId: event.channel, - userId: event.user || "", - teamId: teamId, - threadTs: event.thread_ts, - messageTs: event.ts, - text: event.text || "", - userDisplayName: - (event as { user_profile?: { display_name?: string } }).user_profile - ?.display_name || "Unknown User", - }; - } - - /** - * Extract user request from mention text - */ - extractUserRequest(text: string): string { - const cleaned = text.replace(/<@[^>]+>/g, "").trim(); - - if (!cleaned) { - return "Hello! How can I help you today?"; - } - - return cleaned; - } - - /** - * Check if user is allowed to use the bot - * Note: User allowlisting removed - all users are allowed by default - */ - isUserAllowed(_userId: string): boolean { - return true; // All users allowed - } - - /** - * Cleanup expired data from session store - */ - async cleanupExpiredData(): Promise { - const deletedCount = await this.sessionManager.cleanupExpired( - this.SESSION_TTL - ); - if (deletedCount > 0) { - logger.info(`Cleanup completed - Deleted ${deletedCount} sessions`); - } - } + private readonly SESSION_TTL = DEFAULTS.SESSION_TTL_MS; + private channelBindingService?: ChannelBindingService; + private agentSettingsStore?: AgentSettingsStore; + private transcriptionService?: TranscriptionService; + private userAgentsStore?: UserAgentsStore; + private agentMetadataStore?: AgentMetadataStore; + private adminStatusCache?: AdminStatusCache; + private commandDispatcher?: CommandDispatcher; + + constructor( + private queueProducer: QueueProducer, + private config: MessageHandlerConfig, + private sessionManager: ISessionManager, + private slackClient: WebClient, + ) {} + + /** + * Set the channel binding service (optional) + */ + setChannelBindingService(service: ChannelBindingService): void { + this.channelBindingService = service; + } + + /** + * Set the agent settings store (optional) + */ + setAgentSettingsStore(store: AgentSettingsStore): void { + this.agentSettingsStore = store; + } + + /** + * Set the transcription service for voice/audio processing (optional) + */ + setTranscriptionService(service: TranscriptionService): void { + this.transcriptionService = service; + } + + /** + * Set user agents store for agent configuration flow + */ + setUserAgentsStore(store: UserAgentsStore): void { + this.userAgentsStore = store; + } + + /** + * Set agent metadata store for agent configuration flow + */ + setAgentMetadataStore(store: AgentMetadataStore): void { + this.agentMetadataStore = store; + } + + /** + * Set admin status cache for permission checks + */ + setAdminStatusCache(cache: AdminStatusCache): void { + this.adminStatusCache = cache; + } + + setCommandDispatcher(dispatcher: CommandDispatcher): void { + this.commandDispatcher = dispatcher; + } + + /** + * Transcribe audio files from Slack message. + * Returns the original message with transcriptions prepended. + */ + private async transcribeAudioFiles( + userRequest: string, + files: any[] | undefined, + slackToken?: string, + ): Promise { + if (!files?.length || !this.transcriptionService) { + return userRequest; + } + + // Filter for audio files + const audioFiles = files.filter((f) => { + const mimetype = f.mimetype?.toLowerCase() || ""; + const filetype = f.filetype?.toLowerCase() || ""; + return ( + mimetype.startsWith("audio/") || + mimetype === "application/ogg" || + ["mp3", "m4a", "wav", "ogg", "opus", "webm", "aac"].includes(filetype) + ); + }); + + if (audioFiles.length === 0) { + return userRequest; + } + + logger.info( + { audioFileCount: audioFiles.length }, + "Attempting to transcribe Slack audio files", + ); + + const transcriptions: string[] = []; + + for (const audioFile of audioFiles) { + try { + // Download the file from Slack + const downloadUrl = + audioFile.url_private_download || audioFile.url_private; + if (!downloadUrl) { + logger.warn( + { fileId: audioFile.id }, + "No download URL for audio file", + ); + continue; + } + + const token = + slackToken || this.config.slack.token || process.env.SLACK_BOT_TOKEN; + const response = await fetch(downloadUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + logger.warn( + { fileId: audioFile.id, status: response.status }, + "Failed to download Slack audio file", + ); + continue; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const mimetype = audioFile.mimetype || "audio/mpeg"; + const filename = + audioFile.name || `audio.${audioFile.filetype || "mp3"}`; + + const result = await this.transcriptionService.transcribe( + buffer, + filename, + mimetype, + ); + + if ("text" in result) { + // Success case + transcriptions.push(`[Voice message]: ${result.text}`); + logger.info( + { fileId: audioFile.id, textLength: result.text.length }, + "Audio transcription successful", + ); + } else if ( + result.error?.includes("No transcription provider configured") + ) { + logger.info("Transcription service not configured - skipping audio"); + break; // No point trying more files + } else { + logger.warn( + { fileId: audioFile.id, error: result.error }, + "Audio transcription failed", + ); + } + } catch (error) { + logger.error( + { fileId: audioFile.id, error: String(error) }, + "Error transcribing audio file", + ); + } + } + + if (transcriptions.length === 0) { + return userRequest; + } + + // Prepend transcriptions to the message + const transcriptionPrefix = transcriptions.join("\n\n"); + if (!userRequest.trim() || userRequest === "[Audio message]") { + return transcriptionPrefix; + } + return `${transcriptionPrefix}\n\n${userRequest}`; + } + + /** + * Get agent options with settings applied + */ + private getAgentOptionsWithSettings( + agentId: string, + ): Promise> { + const baseOptions = { + ...this.config.agentOptions, + timeoutMinutes: this.config.sessionTimeoutMinutes.toString(), + }; + return resolveAgentOptions(agentId, baseOptions, this.agentSettingsStore); + } + + /** + * Get bot ID from configuration + */ + private getBotId(): string { + return this.config.slack.botId || "default-slack-bot"; + } + + /** + * Set thread status indicator + */ + private async setThreadStatus( + channelId: string, + threadTs: string, + status: string, + ): Promise { + try { + logger.info( + `Setting thread status "${status}" for channel ${channelId}, thread ${threadTs}`, + ); + await this.slackClient.apiCall("assistant.threads.setStatus", { + channel_id: channelId, + thread_ts: threadTs, + status, + loading_messages: [ + "warming up...", + "getting ready...", + "thinking about it...", + "on it...", + "loading...", + "waking up...", + "brewing some thoughts...", + "putting on thinking cap...", + ], + }); + logger.info(`Successfully set thread status "${status}"`); + } catch (error) { + // Non-critical - just log + logger.warn(`Failed to set thread status: ${error}`); + } + } + + /** + * Handle user request by routing to appropriate queue + */ + async handleUserRequest( + context: SlackContext, + userRequest: string, + client: WebClient, + files?: any[], + ): Promise { + const requestStartTime = Date.now(); + logger.info( + `[TIMING] handleUserRequest started at: ${new Date(requestStartTime).toISOString()}`, + ); + logger.info( + `📨 Handling request from user ${context.userId} in thread ${context.threadTs || context.messageTs}`, + ); + + // Transcribe audio files if present + const processedRequest = await this.transcribeAudioFiles( + userRequest, + files, + client.token, + ); + + // CRITICAL: Always use thread_ts for thread identification + // For root messages: thread_ts is undefined, so we use message_ts + // For replies in thread: thread_ts points to the root message + // This ensures all messages in a thread share the same worker + const normalizedThreadTs = context.threadTs || context.messageTs; + + // Log for debugging thread routing + logger.info( + `Thread routing - messageTs: ${context.messageTs}, threadTs: ${context.threadTs}, normalizedThreadTs: ${normalizedThreadTs}`, + ); + + // Generate session key with normalized threadTs - use thread creator as userId for consistency + const threadCreatorSessionKey = generateSessionKey({ + platform: "slack", + channelId: context.channelId, + userId: context.userId, + conversationId: normalizedThreadTs, + messageId: context.messageTs, + }); + + // Check if this is a Direct Message channel (DMs start with 'D') + const isDirectMessage = context.channelId.startsWith("D"); + + // Handle slash commands via shared dispatcher before normal message routing + if (this.commandDispatcher) { + const handled = await this.commandDispatcher.tryHandleSlashText( + userRequest, + { + platform: "slack", + userId: context.userId, + channelId: context.channelId, + teamId: context.teamId, + isGroup: !isDirectMessage, + conversationId: normalizedThreadTs, + reply: createSlackThreadReply( + client, + context.channelId, + normalizedThreadTs, + ), + }, + ); + + if (handled) return; + } + + // Resolve agent ID from channel binding or space fallback + const resolved = await resolveAgentId({ + platform: "slack", + userId: context.userId, + channelId: context.channelId, + isGroup: !isDirectMessage, + teamId: context.teamId, + channelBindingService: this.channelBindingService, + sendConfigPrompt: () => + this.sendConfigurationPrompt(context, client, isDirectMessage), + }); + if (resolved.promptSent) return; + const agentId = resolved.agentId; + + // Only check thread ownership for non-DM channels + if (!isDirectMessage) { + const ownershipCheck = await this.sessionManager.validateThreadOwnership( + context.channelId, + normalizedThreadTs, + context.userId, + ); + + if (!ownershipCheck.allowed && ownershipCheck.owner) { + logger.warn( + `User ${context.userId} tried to interact with thread owned by ${ownershipCheck.owner}`, + ); + + // Send ownership message + await client.chat.postMessage({ + channel: context.channelId, + thread_ts: normalizedThreadTs, + text: `This thread is owned by <@${ownershipCheck.owner}>. Only the thread creator can interact with the bot in this conversation.`, + mrkdwn: true, + }); + + return; + } + } else { + logger.info( + `Skipping thread ownership check for DM channel ${context.channelId}`, + ); + } + + // Get existing session if any + const existingSession = await this.sessionManager.findSessionByThread( + context.channelId, + normalizedThreadTs, + ); + + const sessionKey = threadCreatorSessionKey; + + logger.info( + `Handling request for session: ${sessionKey} (threadTs: ${normalizedThreadTs})`, + ); + + // Check turn count to prevent infinite loops + const maxTurns = process.env.MAX_TURNS + ? parseInt(process.env.MAX_TURNS, 10) + : 50; + const currentTurnCount = (existingSession?.turnCount || 0) + 1; + + if (currentTurnCount > maxTurns) { + logger.warn( + `Thread ${normalizedThreadTs} exceeded MAX_TURNS (${maxTurns}). Preventing infinite loop.`, + ); + await client.chat.postMessage({ + channel: context.channelId, + thread_ts: normalizedThreadTs, + text: `⚠️ This conversation has exceeded the maximum turn limit (${maxTurns} turns). Please start a new thread to continue.`, + }); + return; + } + + logger.info(`Turn count: ${currentTurnCount}/${maxTurns}`); + + try { + const conversationId = normalizedThreadTs; + + // Create thread session with turn count + const threadSession: ThreadSession = { + conversationId, + channelId: context.channelId, + userId: context.userId, + threadCreator: context.userId, // Store the thread creator + lastActivity: Date.now(), + createdAt: Date.now(), + turnCount: currentTurnCount, + }; + + await this.sessionManager.setSession(threadSession); + + // Determine if this is a new conversation + // A conversation is new only if this message is the ROOT of the thread (messageTs === threadTs) + // OR if there's no thread_ts at all (first message in a channel/DM) + const isNewConversation = + context.messageTs === normalizedThreadTs && !existingSession; + + // Fetch agent settings and merge with config defaults + const agentOptions = await this.getAgentOptionsWithSettings(agentId); + + if (isNewConversation) { + await this.sessionManager.setSession(threadSession); + } else { + await this.sessionManager.setSession(threadSession); + } + + const payload = buildMessagePayload({ + platform: "slack", + userId: context.userId, + botId: this.getBotId(), + conversationId, + teamId: context.teamId, + agentId, + messageId: context.messageTs, + messageText: processedRequest, + channelId: context.channelId, + platformMetadata: { + teamId: context.teamId, + userDisplayName: context.userDisplayName, + responseChannel: context.channelId, + responseId: context.messageTs, + originalMessageId: context.messageTs, + botResponseId: threadSession.botResponseId, + files: files || [], + }, + agentOptions, + }); + + const jobId = await this.queueProducer.enqueueMessage(payload); + + // Set status indicator + await this.setThreadStatus( + context.channelId, + conversationId, + "is scheduling..", + ); + + logger.info( + `Enqueued ${isNewConversation ? "direct message" : "thread message"} job ${jobId} for ${isNewConversation ? `session ${sessionKey}` : `conversation ${conversationId}`}`, + ); + } catch (error) { + logger.error( + `Failed to handle request for session ${sessionKey}:`, + error, + ); + + // Handle all errors the same way - let the worker decide what to show + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorMsg = `❌ *Error:* ${errorMessage || "Unknown error occurred"}`; + + // Post error message in thread + const threadTs = context.threadTs || context.messageTs; + await client.chat.postMessage({ + channel: context.channelId, + thread_ts: threadTs, + text: errorMsg, + mrkdwn: true, + }); + + // Clean up session + await this.sessionManager.deleteSession(sessionKey); + } + } + + /** + * Send a configuration prompt to the user when no agent is bound to a channel. + * Returns true if the prompt was sent (caller should stop processing). + * Returns false if the prompt could not be sent (caller should fallback). + */ + private async sendConfigurationPrompt( + context: SlackContext, + client: WebClient, + isDirectMessage: boolean, + ): Promise { + // Need all stores to send a config prompt + if (!this.userAgentsStore || !this.agentMetadataStore) { + return false; + } + + try { + const { sessionId } = await getSessionStore().createSession( + { + userId: context.userId, + platform: "slack", + channelId: context.channelId, + teamId: context.teamId, + }, + getSettingsTokenTtlMs(), + ); + + const configUrl = buildSessionUrl(sessionId); + const threadTs = context.threadTs || context.messageTs; + + if (isDirectMessage) { + // DM - reply directly in the conversation + await client.chat.postMessage({ + channel: context.channelId, + thread_ts: threadTs, + text: `Welcome! To get started, please configure which agent should handle this conversation.`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Welcome! To get started, please configure which agent should handle this conversation.`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Configure Agent" }, + url: configUrl, + style: "primary", + action_id: "configure_agent", + }, + ], + }, + ], + }); + } else { + // Group channel - check admin permissions + const canConfigure = await this.checkCanConfigure( + "slack", + context.channelId, + context.userId, + context.teamId, + ); + + if (!canConfigure.allowed) { + await client.chat.postEphemeral({ + channel: context.channelId, + user: context.userId, + text: + canConfigure.reason || + "Only admins can configure the bot for this channel.", + }); + return true; + } + + // Slack chat.postMessage needs a channel ID. Open (or find) a DM channel first. + let dmChannelId: string | undefined; + try { + const opened = await client.conversations.open({ + users: context.userId, + return_im: true, + }); + dmChannelId = opened.channel?.id; + } catch (error) { + logger.warn("Failed to open DM channel for configuration link", { + error, + userId: context.userId, + }); + } + + if (dmChannelId) { + await client.chat.postMessage({ + channel: dmChannelId, + text: `Please configure which agent should handle messages in <#${context.channelId}>`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Please configure which agent should handle messages in <#${context.channelId}>`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Configure Agent" }, + url: configUrl, + style: "primary", + action_id: "configure_agent", + }, + ], + }, + ], + }); + } else { + // If DM can't be opened (scopes, Slack restrictions), send ephemeral with a button. + await client.chat.postEphemeral({ + channel: context.channelId, + user: context.userId, + text: `Configure which agent should handle messages in <#${context.channelId}>: ${configUrl}`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Configure which agent should handle messages in <#${context.channelId}>`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Configure Agent" }, + url: configUrl, + style: "primary", + action_id: "configure_agent", + }, + ], + }, + ], + }); + return true; + } + + // Ephemeral reply in the channel + await client.chat.postEphemeral({ + channel: context.channelId, + user: context.userId, + text: "I sent you a DM with a link to configure your agent for this channel.", + }); + } + + logger.info( + `Sent configuration prompt to user ${context.userId} for channel ${context.channelId}`, + ); + return true; + } catch (error) { + logger.error("Failed to send configuration prompt", { error }); + return false; + } + } + + /** + * Check if a user can configure an agent for a channel. + * DMs always allow. Groups check admin status with fallback to first-user. + */ + private async checkCanConfigure( + platform: string, + channelId: string, + userId: string, + teamId?: string, + ): Promise<{ allowed: boolean; reason?: string }> { + // Always allow in DMs + if (channelId.startsWith("D")) { + return { allowed: true }; + } + + // Check if already configured by someone else + if (this.channelBindingService) { + const existing = await this.channelBindingService.getBinding( + platform, + channelId, + teamId, + ); + if (existing?.configuredBy && existing.configuredBy !== userId) { + // Already configured by someone else - check if current user is admin + const isAdmin = await this.isSlackWorkspaceAdmin(userId); + if (isAdmin !== true) { + return { + allowed: false, + reason: `This channel is already configured by another user. Only workspace admins can reconfigure.`, + }; + } + } + } + + // Try admin check + const isAdmin = await this.isSlackWorkspaceAdmin(userId); + + if (isAdmin === true) { + return { allowed: true }; + } + + if (isAdmin === false) { + return { + allowed: false, + reason: + "Only workspace admins can configure the bot for group channels.", + }; + } + + // isAdmin === null (API error) - fallback to first-user + if (this.channelBindingService) { + const existing = await this.channelBindingService.getBinding( + platform, + channelId, + teamId, + ); + if (!existing) { + logger.info( + `Admin check failed for ${userId}, using first-user fallback`, + ); + return { allowed: true }; + } + } + + return { allowed: true }; + } + + /** + * Check if a Slack user is a workspace admin. + * Returns true/false for definitive answer, null if API check failed. + */ + private async isSlackWorkspaceAdmin(userId: string): Promise { + // Check cache first + if (this.adminStatusCache) { + const cached = await this.adminStatusCache.getStatus( + "slack", + "workspace", + userId, + ); + if (cached !== null) return cached; + } + + try { + const userInfo = await this.slackClient.users.info({ user: userId }); + const isAdmin = + userInfo.user?.is_admin || userInfo.user?.is_owner || false; + + // Cache the result + if (this.adminStatusCache) { + await this.adminStatusCache.setStatus( + "slack", + "workspace", + userId, + isAdmin, + ); + } + + return isAdmin; + } catch (error) { + logger.warn(`Failed to check Slack admin status for ${userId}`, { + error, + }); + return null; + } + } + + /** + * Extract Slack context from event + */ + extractSlackContext( + event: SlackMessageEvent, + bodyTeamId?: string, + ): SlackContext { + // Extract teamId from event.team (optional) or body.team_id (always present) + const teamId = event.team || bodyTeamId || ""; + + return { + channelId: event.channel, + userId: event.user || "", + teamId: teamId, + threadTs: event.thread_ts, + messageTs: event.ts, + text: event.text || "", + userDisplayName: + (event as { user_profile?: { display_name?: string } }).user_profile + ?.display_name || "Unknown User", + }; + } + + /** + * Extract user request from mention text + */ + extractUserRequest(text: string): string { + const cleaned = text.replace(/<@[^>]+>/g, "").trim(); + + if (!cleaned) { + return "Hello! How can I help you today?"; + } + + return cleaned; + } + + /** + * Check if user is allowed to use the bot + * Note: User allowlisting removed - all users are allowed by default + */ + isUserAllowed(_userId: string): boolean { + return true; // All users allowed + } + + /** + * Cleanup expired data from session store + */ + async cleanupExpiredData(): Promise { + const deletedCount = await this.sessionManager.cleanupExpired( + this.SESSION_TTL, + ); + if (deletedCount > 0) { + logger.info(`Cleanup completed - Deleted ${deletedCount} sessions`); + } + } } diff --git a/packages/gateway/src/whatsapp/auth-adapter.ts b/packages/gateway/src/whatsapp/auth-adapter.ts index 93d582e41..730033658 100644 --- a/packages/gateway/src/whatsapp/auth-adapter.ts +++ b/packages/gateway/src/whatsapp/auth-adapter.ts @@ -5,11 +5,12 @@ import { createLogger } from "@lobu/core"; import type { AuthProvider, PlatformAuthAdapter } from "../auth/platform-auth"; +import { buildSessionUrl } from "../auth/settings/session-store"; import { - buildSettingsUrl, - formatSettingsTokenTtl, - generateSettingsToken, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, } from "../auth/settings/token-service"; +import { getSessionStore } from "../routes/public/settings-auth"; import type { BaileysClient } from "./connection/baileys-client"; const logger = createLogger("whatsapp-auth-adapter"); @@ -19,92 +20,94 @@ const logger = createLogger("whatsapp-auth-adapter"); * Sends a settings link where users can configure Claude auth, MCP, network, etc. */ export class WhatsAppAuthAdapter implements PlatformAuthAdapter { - constructor( - private client: BaileysClient, - _publicGatewayUrl: string - ) {} + constructor( + private client: BaileysClient, + _publicGatewayUrl: string, + ) {} - /** - * Send authentication required prompt with settings link. - * The settings page handles Claude OAuth, MCP config, network access, etc. - */ - async sendAuthPrompt( - userId: string, - channelId: string, - _conversationId: string, - _providers: AuthProvider[], - platformMetadata?: Record - ): Promise { - const chatJid = (platformMetadata?.jid as string) || channelId; - const agentId = (platformMetadata?.agentId as string) || channelId; + /** + * Send authentication required prompt with settings link. + * The settings page handles Claude OAuth, MCP config, network access, etc. + */ + async sendAuthPrompt( + userId: string, + channelId: string, + _conversationId: string, + _providers: AuthProvider[], + platformMetadata?: Record, + ): Promise { + const chatJid = (platformMetadata?.jid as string) || channelId; + const agentId = (platformMetadata?.agentId as string) || channelId; - // Generate settings token (configured TTL, default 1 hour) - const token = generateSettingsToken(agentId, userId, "whatsapp"); - const settingsUrl = buildSettingsUrl(token); - const ttlLabel = formatSettingsTokenTtl(); + const { sessionId } = await getSessionStore().createSession( + { userId, platform: "whatsapp", agentId }, + getSettingsTokenTtlMs(), + ); + const settingsUrl = buildSessionUrl(sessionId); + const ttlLabel = formatSettingsTokenTtl(); - const message = [ - "*Setup Required*", - "", - "You need to add a model provider to use this bot.", - "Configure it using this link:", - "", - settingsUrl, - "", - `_Link expires in ${ttlLabel}._`, - ].join("\n"); + const message = [ + "*Setup Required*", + "", + "You need to add a model provider to use this bot.", + "Configure it using this link:", + "", + settingsUrl, + "", + `_Link expires in ${ttlLabel}._`, + ].join("\n"); - try { - await this.client.sendMessage(chatJid, { text: message }); - logger.info({ chatJid, userId, agentId }, "Sent settings link"); - } catch (error) { - logger.error({ error, chatJid }, "Failed to send settings link"); - throw error; - } - } + try { + await this.client.sendMessage(chatJid, { text: message }); + logger.info({ chatJid, userId, agentId }, "Sent settings link"); + } catch (error) { + logger.error({ error, chatJid }, "Failed to send settings link"); + throw error; + } + } - /** - * Send authentication success message. - */ - async sendAuthSuccess( - userId: string, - channelId: string, - provider: AuthProvider - ): Promise { - const message = [ - `*Authentication Successful!*`, - "", - `You're now connected to ${provider.name}.`, - "", - "Send your message again to continue.", - ].join("\n"); + /** + * Send authentication success message. + */ + async sendAuthSuccess( + userId: string, + channelId: string, + provider: AuthProvider, + ): Promise { + const message = [ + `*Authentication Successful!*`, + "", + `You're now connected to ${provider.name}.`, + "", + "Send your message again to continue.", + ].join("\n"); - try { - await this.client.sendMessage(channelId, { text: message }); - logger.info( - { channelId, userId, provider: provider.id }, - "Sent auth success message" - ); - } catch (error) { - logger.error({ error, channelId }, "Failed to send auth success message"); - } - } + try { + await this.client.sendMessage(channelId, { text: message }); + logger.info( + { channelId, userId, provider: provider.id }, + "Sent auth success message", + ); + } catch (error) { + logger.error({ error, channelId }, "Failed to send auth success message"); + } + } - /** - * No longer handling auth responses - settings page handles everything. - */ - async handleAuthResponse( - _channelId: string, - _userId: string, - _text: string - ): Promise { - return false; - } + /** + * No longer handling auth responses - settings page handles everything. + */ + async handleAuthResponse( + _channelId: string, + _userId: string, + _text: string, + ): Promise { + return false; + } - /** - * No pending auth sessions anymore. - */ - hasPendingAuth(_channelId: string): boolean { - return false; - } + /** + * No pending auth sessions anymore. + */ + hasPendingAuth(_channelId: string): boolean { + return false; + } } diff --git a/packages/gateway/src/whatsapp/events/message-handler.ts b/packages/gateway/src/whatsapp/events/message-handler.ts index e7171a3a4..d98a70a11 100644 --- a/packages/gateway/src/whatsapp/events/message-handler.ts +++ b/packages/gateway/src/whatsapp/events/message-handler.ts @@ -5,32 +5,32 @@ */ import { - type AgentOptions as CoreAgentOptions, - createLogger, - generateTraceId, + type AgentOptions as CoreAgentOptions, + createLogger, + generateTraceId, } from "@lobu/core"; import { - type BaileysEventMap, - extractMessageContent, - normalizeMessageContent, - type proto, - type WAMessage, + type BaileysEventMap, + extractMessageContent, + normalizeMessageContent, + type proto, + type WAMessage, } from "@whiskeysockets/baileys"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; import { - type AgentSettingsStore, - buildSettingsUrl, - formatSettingsTokenTtl, - generateChannelSettingsToken, - generateSettingsToken, + type AgentSettingsStore, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, } from "../../auth/settings"; +import { buildSessionUrl } from "../../auth/settings/session-store"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChannelBindingService } from "../../channels"; import type { QueueProducer } from "../../infrastructure/queue/queue-producer"; +import { getSessionStore } from "../../routes/public/settings-auth"; import { - buildMessagePayload, - resolveAgentId, - resolveAgentOptions, + buildMessagePayload, + resolveAgentId, + resolveAgentOptions, } from "../../services/platform-helpers"; import type { TranscriptionService } from "../../services/transcription-service"; import type { ISessionManager } from "../../session"; @@ -38,15 +38,15 @@ import type { WhatsAppAuthAdapter } from "../auth-adapter"; import type { WhatsAppConfig } from "../config"; import type { BaileysClient } from "../connection/baileys-client"; import type { - ExtractedMedia, - MediaExtractionError, - WhatsAppFileHandler, + ExtractedMedia, + MediaExtractionError, + WhatsAppFileHandler, } from "../file-handler"; import { - isGroupJid, - jidToE164, - normalizeE164, - type WhatsAppContext, + isGroupJid, + jidToE164, + normalizeE164, + type WhatsAppContext, } from "../types"; const logger = createLogger("whatsapp-message-handler"); @@ -54,705 +54,708 @@ const logger = createLogger("whatsapp-message-handler"); type AgentOptions = CoreAgentOptions; interface StoredMessage { - id: string; - text: string; - fromMe: boolean; - senderName?: string; - timestamp: number; + id: string; + text: string; + fromMe: boolean; + senderName?: string; + timestamp: number; } interface ConversationHistory { - messages: StoredMessage[]; - lastUpdated: number; + messages: StoredMessage[]; + lastUpdated: number; } /** * WhatsApp message handler. */ export class WhatsAppMessageHandler { - private seen = new Set(); - private groupMetaCache = new Map< - string, - { subject?: string; participants?: string[]; expires: number } - >(); - private conversationHistory = new Map(); - private readonly GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes - private isRunning = false; - private authAdapter?: WhatsAppAuthAdapter; - private fileHandler?: WhatsAppFileHandler; - private channelBindingService?: ChannelBindingService; - private agentSettingsStore?: AgentSettingsStore; - private transcriptionService?: TranscriptionService; - private userAgentsStore?: UserAgentsStore; - private agentMetadataStore?: AgentMetadataStore; - - constructor( - private client: BaileysClient, - private config: WhatsAppConfig, - private queueProducer: QueueProducer, - _sessionManager: ISessionManager, // Reserved for future use - private agentOptions: AgentOptions - ) {} - - /** - * Set the channel binding service (optional) - */ - setChannelBindingService(service: ChannelBindingService): void { - this.channelBindingService = service; - } - - /** - * Set the agent settings store (optional) - */ - setAgentSettingsStore(store: AgentSettingsStore): void { - this.agentSettingsStore = store; - } - - /** - * Set the transcription service (optional) - */ - setTranscriptionService(service: TranscriptionService): void { - this.transcriptionService = service; - } - - setUserAgentsStore(store: UserAgentsStore): void { - this.userAgentsStore = store; - } - - setAgentMetadataStore(store: AgentMetadataStore): void { - this.agentMetadataStore = store; - } - - /** - * Send a configuration prompt for WhatsApp. - * WhatsApp always uses first-user fallback for groups (limited admin API). - */ - private async sendWhatsAppConfigPrompt( - context: WhatsAppContext - ): Promise { - if (!this.userAgentsStore || !this.agentMetadataStore) { - return false; - } - - try { - const userId = context.senderE164 || context.senderJid; - - const token = generateChannelSettingsToken( - userId, - "whatsapp", - context.chatJid - ); - const configUrl = buildSettingsUrl(token); - - await this.client.sendMessage(context.chatJid, { - text: `Welcome! To get started, please configure which agent should handle messages here.\n\nConfigure: ${configUrl}`, - }); - - logger.info( - `Sent WhatsApp configuration prompt to ${userId} in ${context.chatJid}` - ); - return true; - } catch (error) { - logger.error("Failed to send WhatsApp config prompt", { error }); - return false; - } - } - - /** - * Get agent options with settings applied. - */ - private getAgentOptionsWithSettings( - agentId: string - ): Promise> { - return resolveAgentOptions( - agentId, - { ...this.agentOptions }, - this.agentSettingsStore - ); - } - - /** - * Set the file handler for extracting media. - */ - setFileHandler(handler: WhatsAppFileHandler): void { - this.fileHandler = handler; - } - - /** - * Set the auth adapter for handling auth responses. - */ - setAuthAdapter(adapter: WhatsAppAuthAdapter): void { - this.authAdapter = adapter; - } - - /** - * Start listening for messages. - */ - start(): void { - if (this.isRunning) return; - this.isRunning = true; - - logger.info( - `WhatsApp message handler config: selfChatEnabled=${this.config.selfChatEnabled}, allowFrom=${JSON.stringify(this.config.allowFrom)}, requireMention=${this.config.requireMention}` - ); - - this.client.on("message", (upsert) => { - logger.info("Message handler received event from client"); - this.handleMessagesUpsert(upsert).catch((err) => { - logger.error({ error: String(err) }, "Error handling message upsert"); - }); - }); - - // Handle reactions (for potential future use, e.g., thumbs up = approve) - this.client.on("reaction", (reactions) => { - this.handleReactions( - reactions as BaileysEventMap["messages.reaction"] - ).catch((err) => { - logger.error({ error: String(err) }, "Error handling reactions"); - }); - }); - - // Handle message updates (edits, deletes) - this.client.on("messageUpdate", (updates) => { - this.handleMessageUpdates( - updates as BaileysEventMap["messages.update"] - ).catch((err) => { - logger.error({ error: String(err) }, "Error handling message updates"); - }); - }); - - // Periodically cleanup expired histories - setInterval(() => this.cleanupExpiredHistories(), 60 * 60 * 1000); // Every hour - - logger.info("WhatsApp message handler started"); - } - - /** - * Stop listening for messages. - */ - stop(): void { - this.isRunning = false; - logger.info("WhatsApp message handler stopped"); - } - - /** - * Handle message upsert events from Baileys. - */ - private async handleMessagesUpsert( - upsert: BaileysEventMap["messages.upsert"] - ): Promise { - logger.info( - { type: upsert.type, messageCount: upsert.messages?.length }, - "handleMessagesUpsert called" - ); - - if (upsert.type !== "notify" && upsert.type !== "append") { - logger.debug({ type: upsert.type }, "Skipping non-notify/append upsert"); - return; - } - - for (const msg of upsert.messages ?? []) { - await this.processMessage(msg, upsert.type); - } - } - - /** - * Process a single message. - */ - private async processMessage( - msg: WAMessage, - upsertType: string - ): Promise { - const id = msg.key?.id; - // DEBUG: Log raw message structure for troubleshooting - const msgKeys = msg.message ? Object.keys(msg.message) : []; - logger.info( - `Raw message: id=${id}, fromMe=${msg.key?.fromMe}, remoteJid=${msg.key?.remoteJid}, msgKeys=[${msgKeys.join(",")}]` - ); - - if (!id) { - logger.info("Skipping message: no ID"); - return; - } - - // Dedupe on message ID (Baileys can emit retries) - // Note: we check seen here but only add to seen AFTER stub/content checks pass - // This handles the case where Baileys first emits a stub, then the real message - if (this.seen.has(id)) { - logger.info({ id }, "Skipping duplicate message"); - return; - } - - const remoteJid = msg.key?.remoteJid; - if (!remoteJid) { - logger.info({ id }, "Skipping message: no remoteJid"); - return; - } - - // Ignore status/broadcast traffic - if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) { - logger.info({ id, remoteJid }, "Skipping status/broadcast message"); - return; - } - - // For @lid (linked device ID) JIDs, prefer remoteJidAlt for response routing - // @lid JIDs are internal WhatsApp IDs that may not route correctly for sending - const remoteJidAlt = (msg.key as { remoteJidAlt?: string })?.remoteJidAlt; - const responseJid = - remoteJid.endsWith("@lid") && remoteJidAlt ? remoteJidAlt : remoteJid; - - if (remoteJidAlt) { - logger.info( - `Message from @lid JID, using remoteJidAlt for responses: ${remoteJid} -> ${responseJid}` - ); - } - - const isGroup = isGroupJid(responseJid); - const participantJid = msg.key?.participant; - - // Get sender info - use responseJid for non-groups to handle @lid -> @s.whatsapp.net resolution - const senderJid = isGroup ? participantJid : responseJid; - const senderE164 = senderJid ? jidToE164(senderJid) : null; - - // Get self info - const selfJid = this.client.getSelfJid(); - const selfE164 = this.client.getSelfE164(); - - // Check if this is from ourselves - const isFromMe = msg.key?.fromMe === true; - const isSelfChat = senderE164 === selfE164; - - logger.info( - `Processing message: id=${id}, remoteJid=${remoteJid}, isFromMe=${isFromMe}, isSelfChat=${isSelfChat}, senderE164=${senderE164}, selfE164=${selfE164}, selfChatEnabled=${this.config.selfChatEnabled}, messageStubType=${msg.messageStubType}, hasMessage=${!!msg.message}` - ); - - // Skip stub messages (system notifications, failed decryption, etc.) - // messageStubType 2 = CIPHERTEXT (failed to decrypt) - if (msg.messageStubType) { - logger.info(`Skipping stub message: type=${msg.messageStubType}`); - return; - } - - // Skip messages with no content (decryption failed) - if (!msg.message) { - logger.warn(`Message ${id} has no content - possible decryption failure`); - return; - } - - // Mark as seen now that we know it's a real message (not stub, has content) - this.seen.add(id); - - // Get raw message keys for logging - const rawMessageKeys = Object.keys(msg.message); - logger.info(`Message ${id} raw keys: ${rawMessageKeys.join(", ")}`); - - // Normalize message content to unwrap nested types (viewOnce, ephemeral, etc.) - const normalizedContent = normalizeMessageContent(msg.message); - const normalizedKeys = normalizedContent - ? Object.keys(normalizedContent) - : []; - if ( - normalizedKeys.length > 0 && - normalizedKeys.join(",") !== rawMessageKeys.join(",") - ) { - logger.info( - `Message ${id} normalized keys: ${normalizedKeys.join(", ")}` - ); - } - - // Check if message is protocol-only (no user content) - // Use BOTH raw and normalized keys to detect user content - const userContentTypes = [ - "conversation", - "extendedTextMessage", - "audioMessage", - "imageMessage", - "videoMessage", - "documentMessage", - "stickerMessage", - ]; - const hasUserContentRaw = userContentTypes.some((type) => - rawMessageKeys.includes(type) - ); - const hasUserContentNormalized = userContentTypes.some((type) => - normalizedKeys.includes(type) - ); - const hasUserContent = hasUserContentRaw || hasUserContentNormalized; - - // Skip pure protocol messages (no user content in raw or normalized) - if ( - rawMessageKeys.length === 1 && - rawMessageKeys[0] === "protocolMessage" - ) { - logger.info(`Skipping protocol message ${id}`); - return; - } - if (rawMessageKeys.includes("protocolMessage") && !hasUserContent) { - logger.info( - `Skipping protocol-only message ${id} (no user content after normalization)` - ); - return; - } - - // IMPORTANT: Baileys delivers our own outbound messages back via messages.upsert. - // We must never process those as new inbound user messages, otherwise the bot can - // respond to itself in a tight loop (and spam). - // - // Self-chat is the only exception: allow fromMe messages only when the chat is - // actually with ourselves. - if (isFromMe) { - if (!this.config.selfChatEnabled) { - logger.info("Skipping own message - selfChat not enabled"); - return; - } - - if (!isSelfChat) { - logger.info( - { id, remoteJid, responseJid }, - "Skipping outbound echo message (not self-chat)" - ); - return; - } - } - - // Authorization check for non-group messages - if (!isGroup && !this.isAllowedSender(senderE164)) { - logger.info( - `Blocked unauthorized sender: ${senderE164}, allowFrom=${JSON.stringify(this.config.allowFrom)}` - ); - return; - } - - logger.info("Message passed authorization checks"); - - // Get group metadata if needed - let groupSubject: string | undefined; - let groupParticipants: string[] | undefined; - if (isGroup) { - const meta = await this.getGroupMeta(responseJid); - groupSubject = meta.subject; - groupParticipants = meta.participants; - } - - // Check mention requirement for groups and self-chat - const mentionedJids = this.extractMentionedJids(msg.message); - const wasMentioned = selfJid - ? (mentionedJids?.includes(selfJid) ?? false) - : false; - - // For self-chat, require mention to prevent loops (bot replies don't have mentions) - // Media messages (voice, image, video, etc.) are allowed through without trigger pattern - if (isSelfChat && this.config.requireMention && !wasMentioned) { - const mediaPlaceholder = this.extractMediaPlaceholder(msg.message); - const hasMedia = mediaPlaceholder !== undefined; - logger.info( - `Self-chat check: id=${id}, hasMedia=${hasMedia}, mediaPlaceholder=${mediaPlaceholder}` - ); - if (!hasMedia) { - // Check for text trigger patterns like "@bot" in message body - const bodyText = this.extractText(msg.message) || ""; - const hasTriggerPattern = /^@\w+/i.test(bodyText.trim()); - logger.info( - `Self-chat trigger check: id=${id}, bodyText="${bodyText.substring(0, 50)}", hasTriggerPattern=${hasTriggerPattern}` - ); - if (!hasTriggerPattern) { - logger.info( - `Skipping self-chat message without trigger pattern: ${id}` - ); - return; - } - } - } - - if (isGroup && this.config.requireMention && !wasMentioned) { - return; - } - - // Mark as read (unless self-chat) - if (!isSelfChat) { - await this.client - .markRead(remoteJid, id, participantJid || undefined) - .catch((err) => { - logger.debug( - { error: String(err) }, - "Failed to mark message as read" - ); - }); - } - - // Skip history/offline catch-up messages (but allow self-chat messages) - if (upsertType === "append" && !isSelfChat) { - logger.info(`Skipping history/append message: ${id}`); - return; - } - - logger.info( - `About to extract text from message ${id}, upsertType=${upsertType}` - ); - - // Debug: Log full message structure - const msgJson = JSON.stringify(msg, null, 2); - logger.info(`FULL_MESSAGE_DEBUG: ${msgJson.substring(0, 2000)}`); - - // Extract media files if file handler is available - let extractedFiles: ExtractedMedia[] = []; - let extractionErrors: MediaExtractionError[] = []; - if (this.fileHandler) { - try { - const result = await this.fileHandler.extractMediaFromMessage(msg); - extractedFiles = result.files; - extractionErrors = result.errors; - if (extractedFiles.length > 0) { - logger.info( - { messageId: id, fileCount: extractedFiles.length }, - "Extracted media files from message" - ); - } - if (extractionErrors.length > 0) { - logger.warn( - { - messageId: id, - errorCount: extractionErrors.length, - errors: extractionErrors, - }, - "Some media extraction failed" - ); - } - } catch (err) { - logger.error( - { error: String(err), messageId: id }, - "Failed to extract media" - ); - } - } - - // Extract message text - let body = this.extractText(msg.message); - if (!body) { - // If we have files but no text, use a placeholder indicating files - if (extractedFiles.length > 0) { - const fileNames = extractedFiles.map((f) => f.name).join(", "); - body = `[Attached: ${fileNames}]`; - } else { - body = this.extractMediaPlaceholder(msg.message); - if (!body) { - logger.info(`No text or media placeholder found in message ${id}`); - return; - } - } - } - - logger.info(`Message ${id} has body: ${body.substring(0, 50)}...`); - - // Check if this is an auth response (e.g., "1" to select provider) - // Use responseJid (mapped JID) for consistency with auth prompt storage - if (this.authAdapter && !isGroup) { - const userId = senderE164 || senderJid || ""; - try { - const handled = await this.authAdapter.handleAuthResponse( - responseJid, - userId, - body - ); - if (handled) { - logger.info({ remoteJid, body }, "Message handled as auth response"); - return; - } - } catch (err) { - logger.error({ error: String(err) }, "Error handling auth response"); - } - } - - // Extract reply context - const replyContext = this.describeReplyContext(msg.message); - - // Build context - use responseJid for routing (handles @lid -> @s.whatsapp.net mapping) - const context: WhatsAppContext = { - senderJid: senderJid || remoteJid, - senderE164: senderE164 ?? undefined, - senderName: msg.pushName ?? undefined, - chatJid: responseJid, // Use responseJid for proper message routing - isGroup, - groupSubject, - groupParticipants, - messageId: id, - timestamp: msg.messageTimestamp - ? Number(msg.messageTimestamp) * 1000 - : undefined, - quotedMessage: replyContext ?? undefined, - mentionedJids, - wasMentioned, - selfJid: selfJid ?? undefined, - selfE164: selfE164 ?? undefined, - }; - - logger.info( - { - from: senderE164 || senderJid, - chatJid: responseJid, - originalJid: remoteJid !== responseJid ? remoteJid : undefined, - isGroup, - body: body.substring(0, 100), - }, - "Inbound message" - ); - - // Store incoming message in conversation history (use responseJid for consistency) - this.storeMessageInHistory(responseJid, { - id, - text: body, - fromMe: false, - senderName: msg.pushName ?? undefined, - timestamp: msg.messageTimestamp - ? Number(msg.messageTimestamp) * 1000 - : Date.now(), - }); - - // Get in-memory conversation history for context - const conversationHistory = this.getConversationHistory(responseJid); - - // Enqueue for processing - await this.enqueueMessage( - id, - body, - context, - extractedFiles, - conversationHistory, - extractionErrors - ); - } - - /** - * Check if sender is allowed. - */ - private isAllowedSender(senderE164: string | null): boolean { - if (!senderE164) return false; - - const { allowFrom, selfChatEnabled } = this.config; - const selfE164 = this.client.getSelfE164(); - - // Self-chat always allowed if enabled - if (selfChatEnabled && senderE164 === selfE164) { - return true; - } - - // Empty allowFrom means allow all - if (!allowFrom || allowFrom.length === 0) { - return true; - } - - // Check wildcard - if (allowFrom.includes("*")) { - return true; - } - - // Check if sender is in allowlist - const normalizedAllowFrom = allowFrom.map(normalizeE164); - return normalizedAllowFrom.includes(normalizeE164(senderE164)); - } - - /** - * Get group metadata with caching. - */ - private async getGroupMeta( - jid: string - ): Promise<{ subject?: string; participants?: string[] }> { - const cached = this.groupMetaCache.get(jid); - if (cached && cached.expires > Date.now()) { - return cached; - } - - const meta = await this.client.getGroupMetadata(jid); - const entry = { - ...meta, - expires: Date.now() + this.GROUP_META_TTL_MS, - }; - this.groupMetaCache.set(jid, entry); - return meta; - } - - /** - * Enqueue message for worker processing. - */ - private async enqueueMessage( - messageId: string, - body: string, - context: WhatsAppContext, - files: ExtractedMedia[] = [], - conversationHistory: Array<{ - role: "user" | "assistant"; - content: string; - name?: string; - }> = [], - mediaErrors: MediaExtractionError[] = [] - ): Promise { - // For 1:1 chats: use chatJid for conversation continuity (all messages share context) - // For groups: use quoted message ID or message ID (explicit reply threading) - const conversationId = context.isGroup - ? context.quotedMessage?.id || messageId - : context.chatJid; - - // Generate trace ID for end-to-end observability - const traceId = generateTraceId(messageId); - - logger.info( - { - traceId, - messageId, - conversationId, - userId: context.senderE164 || context.senderJid, - }, - "Message received" - ); - - // Resolve agent ID from channel binding or space fallback - const resolved = await resolveAgentId({ - platform: "whatsapp", - userId: context.senderE164 || context.senderJid, - channelId: context.chatJid, - isGroup: context.isGroup, - channelBindingService: this.channelBindingService, - sendConfigPrompt: () => this.sendWhatsAppConfigPrompt(context), - }); - if (resolved.promptSent) return; - const agentId = resolved.agentId; - - // Handle /configure command - send settings magic link - if (body.trim().toLowerCase() === "/configure") { - const userId = context.senderE164 || context.senderJid; - logger.info(`User ${userId} requested /configure for agent ${agentId}`); - try { - const token = generateSettingsToken(agentId, userId, "whatsapp"); - const settingsUrl = buildSettingsUrl(token); - const ttlLabel = formatSettingsTokenTtl(); - - await this.client.sendMessage(context.chatJid, { - text: `Here's your settings link (valid for ${ttlLabel}):\n${settingsUrl}\n\nUse this page to configure your agent's model, network access, and more.`, - }); - logger.info(`Sent settings link to user ${userId}`); - } catch (error) { - logger.error("Failed to generate settings link", { error }); - await this.client.sendMessage(context.chatJid, { - text: "Sorry, I couldn't generate a settings link. Please try again later.", - }); - } - return; - } - - // Transcribe audio files if transcription service is available - let transcribedBody = body; - const audioFiles = files.filter( - (f) => f.mimetype.startsWith("audio/") || f.mimetype === "application/ogg" - ); - - // Check if we received an audio message but couldn't download it - const hadAudioMessage = - body === "" || body.includes(""); - const audioError = mediaErrors.find((e) => e.mediaType === "audioMessage"); - if (hadAudioMessage && audioFiles.length === 0) { - // Audio message was detected but download failed - inform Claude clearly - const errorDetail = audioError?.error || "Unknown error"; - transcribedBody = `[Voice message received - audio download failed] + private seen = new Set(); + private groupMetaCache = new Map< + string, + { subject?: string; participants?: string[]; expires: number } + >(); + private conversationHistory = new Map(); + private readonly GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes + private isRunning = false; + private authAdapter?: WhatsAppAuthAdapter; + private fileHandler?: WhatsAppFileHandler; + private channelBindingService?: ChannelBindingService; + private agentSettingsStore?: AgentSettingsStore; + private transcriptionService?: TranscriptionService; + private userAgentsStore?: UserAgentsStore; + private agentMetadataStore?: AgentMetadataStore; + + constructor( + private client: BaileysClient, + private config: WhatsAppConfig, + private queueProducer: QueueProducer, + _sessionManager: ISessionManager, // Reserved for future use + private agentOptions: AgentOptions, + ) {} + + /** + * Set the channel binding service (optional) + */ + setChannelBindingService(service: ChannelBindingService): void { + this.channelBindingService = service; + } + + /** + * Set the agent settings store (optional) + */ + setAgentSettingsStore(store: AgentSettingsStore): void { + this.agentSettingsStore = store; + } + + /** + * Set the transcription service (optional) + */ + setTranscriptionService(service: TranscriptionService): void { + this.transcriptionService = service; + } + + setUserAgentsStore(store: UserAgentsStore): void { + this.userAgentsStore = store; + } + + setAgentMetadataStore(store: AgentMetadataStore): void { + this.agentMetadataStore = store; + } + + /** + * Send a configuration prompt for WhatsApp. + * WhatsApp always uses first-user fallback for groups (limited admin API). + */ + private async sendWhatsAppConfigPrompt( + context: WhatsAppContext, + ): Promise { + if (!this.userAgentsStore || !this.agentMetadataStore) { + return false; + } + + try { + const userId = context.senderE164 || context.senderJid; + + const { sessionId } = await getSessionStore().createSession( + { userId, platform: "whatsapp", channelId: context.chatJid }, + getSettingsTokenTtlMs(), + ); + const configUrl = buildSessionUrl(sessionId); + + await this.client.sendMessage(context.chatJid, { + text: `Welcome! To get started, please configure which agent should handle messages here.\n\nConfigure: ${configUrl}`, + }); + + logger.info( + `Sent WhatsApp configuration prompt to ${userId} in ${context.chatJid}`, + ); + return true; + } catch (error) { + logger.error("Failed to send WhatsApp config prompt", { error }); + return false; + } + } + + /** + * Get agent options with settings applied. + */ + private getAgentOptionsWithSettings( + agentId: string, + ): Promise> { + return resolveAgentOptions( + agentId, + { ...this.agentOptions }, + this.agentSettingsStore, + ); + } + + /** + * Set the file handler for extracting media. + */ + setFileHandler(handler: WhatsAppFileHandler): void { + this.fileHandler = handler; + } + + /** + * Set the auth adapter for handling auth responses. + */ + setAuthAdapter(adapter: WhatsAppAuthAdapter): void { + this.authAdapter = adapter; + } + + /** + * Start listening for messages. + */ + start(): void { + if (this.isRunning) return; + this.isRunning = true; + + logger.info( + `WhatsApp message handler config: selfChatEnabled=${this.config.selfChatEnabled}, allowFrom=${JSON.stringify(this.config.allowFrom)}, requireMention=${this.config.requireMention}`, + ); + + this.client.on("message", (upsert) => { + logger.info("Message handler received event from client"); + this.handleMessagesUpsert(upsert).catch((err) => { + logger.error({ error: String(err) }, "Error handling message upsert"); + }); + }); + + // Handle reactions (for potential future use, e.g., thumbs up = approve) + this.client.on("reaction", (reactions) => { + this.handleReactions( + reactions as BaileysEventMap["messages.reaction"], + ).catch((err) => { + logger.error({ error: String(err) }, "Error handling reactions"); + }); + }); + + // Handle message updates (edits, deletes) + this.client.on("messageUpdate", (updates) => { + this.handleMessageUpdates( + updates as BaileysEventMap["messages.update"], + ).catch((err) => { + logger.error({ error: String(err) }, "Error handling message updates"); + }); + }); + + // Periodically cleanup expired histories + setInterval(() => this.cleanupExpiredHistories(), 60 * 60 * 1000); // Every hour + + logger.info("WhatsApp message handler started"); + } + + /** + * Stop listening for messages. + */ + stop(): void { + this.isRunning = false; + logger.info("WhatsApp message handler stopped"); + } + + /** + * Handle message upsert events from Baileys. + */ + private async handleMessagesUpsert( + upsert: BaileysEventMap["messages.upsert"], + ): Promise { + logger.info( + { type: upsert.type, messageCount: upsert.messages?.length }, + "handleMessagesUpsert called", + ); + + if (upsert.type !== "notify" && upsert.type !== "append") { + logger.debug({ type: upsert.type }, "Skipping non-notify/append upsert"); + return; + } + + for (const msg of upsert.messages ?? []) { + await this.processMessage(msg, upsert.type); + } + } + + /** + * Process a single message. + */ + private async processMessage( + msg: WAMessage, + upsertType: string, + ): Promise { + const id = msg.key?.id; + // DEBUG: Log raw message structure for troubleshooting + const msgKeys = msg.message ? Object.keys(msg.message) : []; + logger.info( + `Raw message: id=${id}, fromMe=${msg.key?.fromMe}, remoteJid=${msg.key?.remoteJid}, msgKeys=[${msgKeys.join(",")}]`, + ); + + if (!id) { + logger.info("Skipping message: no ID"); + return; + } + + // Dedupe on message ID (Baileys can emit retries) + // Note: we check seen here but only add to seen AFTER stub/content checks pass + // This handles the case where Baileys first emits a stub, then the real message + if (this.seen.has(id)) { + logger.info({ id }, "Skipping duplicate message"); + return; + } + + const remoteJid = msg.key?.remoteJid; + if (!remoteJid) { + logger.info({ id }, "Skipping message: no remoteJid"); + return; + } + + // Ignore status/broadcast traffic + if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) { + logger.info({ id, remoteJid }, "Skipping status/broadcast message"); + return; + } + + // For @lid (linked device ID) JIDs, prefer remoteJidAlt for response routing + // @lid JIDs are internal WhatsApp IDs that may not route correctly for sending + const remoteJidAlt = (msg.key as { remoteJidAlt?: string })?.remoteJidAlt; + const responseJid = + remoteJid.endsWith("@lid") && remoteJidAlt ? remoteJidAlt : remoteJid; + + if (remoteJidAlt) { + logger.info( + `Message from @lid JID, using remoteJidAlt for responses: ${remoteJid} -> ${responseJid}`, + ); + } + + const isGroup = isGroupJid(responseJid); + const participantJid = msg.key?.participant; + + // Get sender info - use responseJid for non-groups to handle @lid -> @s.whatsapp.net resolution + const senderJid = isGroup ? participantJid : responseJid; + const senderE164 = senderJid ? jidToE164(senderJid) : null; + + // Get self info + const selfJid = this.client.getSelfJid(); + const selfE164 = this.client.getSelfE164(); + + // Check if this is from ourselves + const isFromMe = msg.key?.fromMe === true; + const isSelfChat = senderE164 === selfE164; + + logger.info( + `Processing message: id=${id}, remoteJid=${remoteJid}, isFromMe=${isFromMe}, isSelfChat=${isSelfChat}, senderE164=${senderE164}, selfE164=${selfE164}, selfChatEnabled=${this.config.selfChatEnabled}, messageStubType=${msg.messageStubType}, hasMessage=${!!msg.message}`, + ); + + // Skip stub messages (system notifications, failed decryption, etc.) + // messageStubType 2 = CIPHERTEXT (failed to decrypt) + if (msg.messageStubType) { + logger.info(`Skipping stub message: type=${msg.messageStubType}`); + return; + } + + // Skip messages with no content (decryption failed) + if (!msg.message) { + logger.warn(`Message ${id} has no content - possible decryption failure`); + return; + } + + // Mark as seen now that we know it's a real message (not stub, has content) + this.seen.add(id); + + // Get raw message keys for logging + const rawMessageKeys = Object.keys(msg.message); + logger.info(`Message ${id} raw keys: ${rawMessageKeys.join(", ")}`); + + // Normalize message content to unwrap nested types (viewOnce, ephemeral, etc.) + const normalizedContent = normalizeMessageContent(msg.message); + const normalizedKeys = normalizedContent + ? Object.keys(normalizedContent) + : []; + if ( + normalizedKeys.length > 0 && + normalizedKeys.join(",") !== rawMessageKeys.join(",") + ) { + logger.info( + `Message ${id} normalized keys: ${normalizedKeys.join(", ")}`, + ); + } + + // Check if message is protocol-only (no user content) + // Use BOTH raw and normalized keys to detect user content + const userContentTypes = [ + "conversation", + "extendedTextMessage", + "audioMessage", + "imageMessage", + "videoMessage", + "documentMessage", + "stickerMessage", + ]; + const hasUserContentRaw = userContentTypes.some((type) => + rawMessageKeys.includes(type), + ); + const hasUserContentNormalized = userContentTypes.some((type) => + normalizedKeys.includes(type), + ); + const hasUserContent = hasUserContentRaw || hasUserContentNormalized; + + // Skip pure protocol messages (no user content in raw or normalized) + if ( + rawMessageKeys.length === 1 && + rawMessageKeys[0] === "protocolMessage" + ) { + logger.info(`Skipping protocol message ${id}`); + return; + } + if (rawMessageKeys.includes("protocolMessage") && !hasUserContent) { + logger.info( + `Skipping protocol-only message ${id} (no user content after normalization)`, + ); + return; + } + + // IMPORTANT: Baileys delivers our own outbound messages back via messages.upsert. + // We must never process those as new inbound user messages, otherwise the bot can + // respond to itself in a tight loop (and spam). + // + // Self-chat is the only exception: allow fromMe messages only when the chat is + // actually with ourselves. + if (isFromMe) { + if (!this.config.selfChatEnabled) { + logger.info("Skipping own message - selfChat not enabled"); + return; + } + + if (!isSelfChat) { + logger.info( + { id, remoteJid, responseJid }, + "Skipping outbound echo message (not self-chat)", + ); + return; + } + } + + // Authorization check for non-group messages + if (!isGroup && !this.isAllowedSender(senderE164)) { + logger.info( + `Blocked unauthorized sender: ${senderE164}, allowFrom=${JSON.stringify(this.config.allowFrom)}`, + ); + return; + } + + logger.info("Message passed authorization checks"); + + // Get group metadata if needed + let groupSubject: string | undefined; + let groupParticipants: string[] | undefined; + if (isGroup) { + const meta = await this.getGroupMeta(responseJid); + groupSubject = meta.subject; + groupParticipants = meta.participants; + } + + // Check mention requirement for groups and self-chat + const mentionedJids = this.extractMentionedJids(msg.message); + const wasMentioned = selfJid + ? (mentionedJids?.includes(selfJid) ?? false) + : false; + + // For self-chat, require mention to prevent loops (bot replies don't have mentions) + // Media messages (voice, image, video, etc.) are allowed through without trigger pattern + if (isSelfChat && this.config.requireMention && !wasMentioned) { + const mediaPlaceholder = this.extractMediaPlaceholder(msg.message); + const hasMedia = mediaPlaceholder !== undefined; + logger.info( + `Self-chat check: id=${id}, hasMedia=${hasMedia}, mediaPlaceholder=${mediaPlaceholder}`, + ); + if (!hasMedia) { + // Check for text trigger patterns like "@bot" in message body + const bodyText = this.extractText(msg.message) || ""; + const hasTriggerPattern = /^@\w+/i.test(bodyText.trim()); + logger.info( + `Self-chat trigger check: id=${id}, bodyText="${bodyText.substring(0, 50)}", hasTriggerPattern=${hasTriggerPattern}`, + ); + if (!hasTriggerPattern) { + logger.info( + `Skipping self-chat message without trigger pattern: ${id}`, + ); + return; + } + } + } + + if (isGroup && this.config.requireMention && !wasMentioned) { + return; + } + + // Mark as read (unless self-chat) + if (!isSelfChat) { + await this.client + .markRead(remoteJid, id, participantJid || undefined) + .catch((err) => { + logger.debug( + { error: String(err) }, + "Failed to mark message as read", + ); + }); + } + + // Skip history/offline catch-up messages (but allow self-chat messages) + if (upsertType === "append" && !isSelfChat) { + logger.info(`Skipping history/append message: ${id}`); + return; + } + + logger.info( + `About to extract text from message ${id}, upsertType=${upsertType}`, + ); + + // Debug: Log full message structure + const msgJson = JSON.stringify(msg, null, 2); + logger.info(`FULL_MESSAGE_DEBUG: ${msgJson.substring(0, 2000)}`); + + // Extract media files if file handler is available + let extractedFiles: ExtractedMedia[] = []; + let extractionErrors: MediaExtractionError[] = []; + if (this.fileHandler) { + try { + const result = await this.fileHandler.extractMediaFromMessage(msg); + extractedFiles = result.files; + extractionErrors = result.errors; + if (extractedFiles.length > 0) { + logger.info( + { messageId: id, fileCount: extractedFiles.length }, + "Extracted media files from message", + ); + } + if (extractionErrors.length > 0) { + logger.warn( + { + messageId: id, + errorCount: extractionErrors.length, + errors: extractionErrors, + }, + "Some media extraction failed", + ); + } + } catch (err) { + logger.error( + { error: String(err), messageId: id }, + "Failed to extract media", + ); + } + } + + // Extract message text + let body = this.extractText(msg.message); + if (!body) { + // If we have files but no text, use a placeholder indicating files + if (extractedFiles.length > 0) { + const fileNames = extractedFiles.map((f) => f.name).join(", "); + body = `[Attached: ${fileNames}]`; + } else { + body = this.extractMediaPlaceholder(msg.message); + if (!body) { + logger.info(`No text or media placeholder found in message ${id}`); + return; + } + } + } + + logger.info(`Message ${id} has body: ${body.substring(0, 50)}...`); + + // Check if this is an auth response (e.g., "1" to select provider) + // Use responseJid (mapped JID) for consistency with auth prompt storage + if (this.authAdapter && !isGroup) { + const userId = senderE164 || senderJid || ""; + try { + const handled = await this.authAdapter.handleAuthResponse( + responseJid, + userId, + body, + ); + if (handled) { + logger.info({ remoteJid, body }, "Message handled as auth response"); + return; + } + } catch (err) { + logger.error({ error: String(err) }, "Error handling auth response"); + } + } + + // Extract reply context + const replyContext = this.describeReplyContext(msg.message); + + // Build context - use responseJid for routing (handles @lid -> @s.whatsapp.net mapping) + const context: WhatsAppContext = { + senderJid: senderJid || remoteJid, + senderE164: senderE164 ?? undefined, + senderName: msg.pushName ?? undefined, + chatJid: responseJid, // Use responseJid for proper message routing + isGroup, + groupSubject, + groupParticipants, + messageId: id, + timestamp: msg.messageTimestamp + ? Number(msg.messageTimestamp) * 1000 + : undefined, + quotedMessage: replyContext ?? undefined, + mentionedJids, + wasMentioned, + selfJid: selfJid ?? undefined, + selfE164: selfE164 ?? undefined, + }; + + logger.info( + { + from: senderE164 || senderJid, + chatJid: responseJid, + originalJid: remoteJid !== responseJid ? remoteJid : undefined, + isGroup, + body: body.substring(0, 100), + }, + "Inbound message", + ); + + // Store incoming message in conversation history (use responseJid for consistency) + this.storeMessageInHistory(responseJid, { + id, + text: body, + fromMe: false, + senderName: msg.pushName ?? undefined, + timestamp: msg.messageTimestamp + ? Number(msg.messageTimestamp) * 1000 + : Date.now(), + }); + + // Get in-memory conversation history for context + const conversationHistory = this.getConversationHistory(responseJid); + + // Enqueue for processing + await this.enqueueMessage( + id, + body, + context, + extractedFiles, + conversationHistory, + extractionErrors, + ); + } + + /** + * Check if sender is allowed. + */ + private isAllowedSender(senderE164: string | null): boolean { + if (!senderE164) return false; + + const { allowFrom, selfChatEnabled } = this.config; + const selfE164 = this.client.getSelfE164(); + + // Self-chat always allowed if enabled + if (selfChatEnabled && senderE164 === selfE164) { + return true; + } + + // Empty allowFrom means allow all + if (!allowFrom || allowFrom.length === 0) { + return true; + } + + // Check wildcard + if (allowFrom.includes("*")) { + return true; + } + + // Check if sender is in allowlist + const normalizedAllowFrom = allowFrom.map(normalizeE164); + return normalizedAllowFrom.includes(normalizeE164(senderE164)); + } + + /** + * Get group metadata with caching. + */ + private async getGroupMeta( + jid: string, + ): Promise<{ subject?: string; participants?: string[] }> { + const cached = this.groupMetaCache.get(jid); + if (cached && cached.expires > Date.now()) { + return cached; + } + + const meta = await this.client.getGroupMetadata(jid); + const entry = { + ...meta, + expires: Date.now() + this.GROUP_META_TTL_MS, + }; + this.groupMetaCache.set(jid, entry); + return meta; + } + + /** + * Enqueue message for worker processing. + */ + private async enqueueMessage( + messageId: string, + body: string, + context: WhatsAppContext, + files: ExtractedMedia[] = [], + conversationHistory: Array<{ + role: "user" | "assistant"; + content: string; + name?: string; + }> = [], + mediaErrors: MediaExtractionError[] = [], + ): Promise { + // For 1:1 chats: use chatJid for conversation continuity (all messages share context) + // For groups: use quoted message ID or message ID (explicit reply threading) + const conversationId = context.isGroup + ? context.quotedMessage?.id || messageId + : context.chatJid; + + // Generate trace ID for end-to-end observability + const traceId = generateTraceId(messageId); + + logger.info( + { + traceId, + messageId, + conversationId, + userId: context.senderE164 || context.senderJid, + }, + "Message received", + ); + + // Resolve agent ID from channel binding or space fallback + const resolved = await resolveAgentId({ + platform: "whatsapp", + userId: context.senderE164 || context.senderJid, + channelId: context.chatJid, + isGroup: context.isGroup, + channelBindingService: this.channelBindingService, + sendConfigPrompt: () => this.sendWhatsAppConfigPrompt(context), + }); + if (resolved.promptSent) return; + const agentId = resolved.agentId; + + // Handle /configure command - send settings magic link + if (body.trim().toLowerCase() === "/configure") { + const userId = context.senderE164 || context.senderJid; + logger.info(`User ${userId} requested /configure for agent ${agentId}`); + try { + const { sessionId } = await getSessionStore().createSession( + { userId, platform: "whatsapp", agentId }, + getSettingsTokenTtlMs(), + ); + const settingsUrl = buildSessionUrl(sessionId); + const ttlLabel = formatSettingsTokenTtl(); + + await this.client.sendMessage(context.chatJid, { + text: `Here's your settings link (valid for ${ttlLabel}):\n${settingsUrl}\n\nUse this page to configure your agent's model, network access, and more.`, + }); + logger.info(`Sent settings link to user ${userId}`); + } catch (error) { + logger.error("Failed to generate settings link", { error }); + await this.client.sendMessage(context.chatJid, { + text: "Sorry, I couldn't generate a settings link. Please try again later.", + }); + } + return; + } + + // Transcribe audio files if transcription service is available + let transcribedBody = body; + const audioFiles = files.filter( + (f) => + f.mimetype.startsWith("audio/") || f.mimetype === "application/ogg", + ); + + // Check if we received an audio message but couldn't download it + const hadAudioMessage = + body === "" || body.includes(""); + const audioError = mediaErrors.find((e) => e.mediaType === "audioMessage"); + if (hadAudioMessage && audioFiles.length === 0) { + // Audio message was detected but download failed - inform Claude clearly + const errorDetail = audioError?.error || "Unknown error"; + transcribedBody = `[Voice message received - audio download failed] The user sent a voice message but the audio file could not be downloaded from WhatsApp's servers. @@ -764,44 +767,44 @@ Please let the user know: 1. You received their voice message but couldn't process it due to a technical issue 2. Ask them to either send the voice message again or type their message instead 3. This is not their fault - it's a WhatsApp infrastructure timing issue`; - logger.warn( - { messageId, body, error: errorDetail }, - "Audio message detected but file download failed" - ); - } else if (audioFiles.length > 0 && this.transcriptionService) { - logger.info( - { messageId, audioFileCount: audioFiles.length }, - "Attempting to transcribe audio files" - ); - - for (const audioFile of audioFiles) { - const result = await this.transcriptionService.transcribe( - audioFile.buffer, - agentId, - audioFile.mimetype - ); - - if ("text" in result) { - // Successful transcription - const transcriptionPrefix = - transcribedBody === "" || - transcribedBody.startsWith("[Attached:") - ? "" // Replace placeholder entirely - : `${transcribedBody}\n\n`; - transcribedBody = `${transcriptionPrefix}[Voice message]: ${result.text}`; - logger.info( - { - messageId, - provider: result.provider, - textLength: result.text.length, - }, - "Audio transcription successful" - ); - } else { - // Transcription not configured or failed - provide context for Claude - const providers = result.availableProviders; - if (result.error.includes("No transcription provider configured")) { - transcribedBody = `[Voice message received - transcription unavailable] + logger.warn( + { messageId, body, error: errorDetail }, + "Audio message detected but file download failed", + ); + } else if (audioFiles.length > 0 && this.transcriptionService) { + logger.info( + { messageId, audioFileCount: audioFiles.length }, + "Attempting to transcribe audio files", + ); + + for (const audioFile of audioFiles) { + const result = await this.transcriptionService.transcribe( + audioFile.buffer, + agentId, + audioFile.mimetype, + ); + + if ("text" in result) { + // Successful transcription + const transcriptionPrefix = + transcribedBody === "" || + transcribedBody.startsWith("[Attached:") + ? "" // Replace placeholder entirely + : `${transcribedBody}\n\n`; + transcribedBody = `${transcriptionPrefix}[Voice message]: ${result.text}`; + logger.info( + { + messageId, + provider: result.provider, + textLength: result.text.length, + }, + "Audio transcription successful", + ); + } else { + // Transcription not configured or failed - provide context for Claude + const providers = result.availableProviders; + if (result.error.includes("No transcription provider configured")) { + transcribedBody = `[Voice message received - transcription unavailable] The user sent a voice message but no transcription provider is configured. Available providers that can be configured: ${providers.join(", ")} @@ -813,408 +816,408 @@ To enable voice transcription: For now, let the user know you received a voice message but couldn't transcribe it, and offer to help them configure transcription.`; - } else { - // Transcription attempt failed - transcribedBody = `[Voice message received - transcription failed] + } else { + // Transcription attempt failed + transcribedBody = `[Voice message received - transcription failed] Error: ${result.error} The user sent a voice message but transcription failed. Let them know and suggest they try again or type their message.`; - } - logger.warn( - { messageId, error: result.error }, - "Audio transcription failed or not configured" - ); - } - } - } - - // Build file metadata for payload - const fileMetadata = files.map((f) => ({ - id: f.id, - name: f.name, - mimetype: f.mimetype, - size: f.size, - })); - - // Fetch agent settings and merge with config defaults - const agentOptions = await this.getAgentOptionsWithSettings(agentId); - - const payload = buildMessagePayload({ - platform: "whatsapp", - userId: context.senderE164 || context.senderJid, - botId: "whatsapp", - conversationId, - teamId: context.isGroup ? context.chatJid : "whatsapp", - agentId, - messageId, - messageText: transcribedBody, - channelId: context.chatJid, - platformMetadata: { - traceId, - agentId, - jid: context.chatJid, - senderJid: context.senderJid, - senderE164: context.senderE164, - senderName: context.senderName, - isGroup: context.isGroup, - groupSubject: context.groupSubject, - quotedMessageId: context.quotedMessage?.id, - wasMentioned: context.wasMentioned, - responseChannel: context.chatJid, - responseId: messageId, - files: fileMetadata.length > 0 ? fileMetadata : undefined, - conversationHistory: - conversationHistory.length > 0 ? conversationHistory : undefined, - }, - agentOptions, - }); - - await this.queueProducer.enqueueMessage(payload); - logger.info( - { - traceId, - messageId, - conversationId, - chatJid: context.chatJid, - fileCount: files.length, - historyCount: conversationHistory.length, - }, - "Message enqueued" - ); - } - - /** - * Extract text from message. - */ - private extractText( - rawMessage: proto.IMessage | null | undefined - ): string | undefined { - if (!rawMessage) { - logger.info("extractText: rawMessage is null/undefined"); - return undefined; - } - - logger.info( - `extractText: rawMessage keys = ${Object.keys(rawMessage).join(", ")}` - ); - - const message = normalizeMessageContent(rawMessage); - if (!message) { - logger.info("extractText: normalizeMessageContent returned null"); - return undefined; - } - - logger.info( - `extractText: normalized message keys = ${Object.keys(message).join(", ")}` - ); - - const extracted = extractMessageContent(message); - const candidates = [message, extracted !== message ? extracted : undefined]; - - for (const candidate of candidates) { - if (!candidate) continue; - - // Check conversation - if ( - typeof candidate.conversation === "string" && - candidate.conversation.trim() - ) { - return candidate.conversation.trim(); - } - - // Check extended text - const extended = candidate.extendedTextMessage?.text; - if (extended?.trim()) return extended.trim(); - - // Check captions - const caption = - candidate.imageMessage?.caption ?? - candidate.videoMessage?.caption ?? - candidate.documentMessage?.caption; - if (caption?.trim()) return caption.trim(); - } - - return undefined; - } - - /** - * Extract media placeholder text. - */ - private extractMediaPlaceholder( - rawMessage: proto.IMessage | null | undefined - ): string | undefined { - if (!rawMessage) return undefined; - - const message = normalizeMessageContent(rawMessage); - if (!message) return undefined; - - if (message.imageMessage) return ""; - if (message.videoMessage) return ""; - if (message.audioMessage) return ""; - if (message.documentMessage) return ""; - if (message.stickerMessage) return ""; - - return undefined; - } - - /** - * Extract mentioned JIDs from message. - */ - private extractMentionedJids( - rawMessage: proto.IMessage | null | undefined - ): string[] | undefined { - if (!rawMessage) return undefined; - - const message = normalizeMessageContent(rawMessage); - if (!message) return undefined; - - const candidates: Array = [ - message.extendedTextMessage?.contextInfo?.mentionedJid, - message.imageMessage?.contextInfo?.mentionedJid, - message.videoMessage?.contextInfo?.mentionedJid, - message.documentMessage?.contextInfo?.mentionedJid, - message.audioMessage?.contextInfo?.mentionedJid, - ]; - - const flattened = candidates.flatMap((arr) => arr ?? []).filter(Boolean); - if (flattened.length === 0) return undefined; - - return Array.from(new Set(flattened)); - } - - /** - * Extract reply context from message. - */ - private describeReplyContext( - rawMessage: proto.IMessage | null | undefined - ): { id?: string; body: string; sender: string } | null { - if (!rawMessage) return null; - - const message = normalizeMessageContent(rawMessage); - if (!message) return null; - - // Get context info from various message types - const contextInfo = - message.extendedTextMessage?.contextInfo ?? - message.imageMessage?.contextInfo ?? - message.videoMessage?.contextInfo ?? - message.documentMessage?.contextInfo ?? - message.audioMessage?.contextInfo; - - if (!contextInfo?.quotedMessage) return null; - - const quoted = normalizeMessageContent(contextInfo.quotedMessage); - if (!quoted) return null; - - const body = - this.extractText(quoted) || this.extractMediaPlaceholder(quoted); - if (!body) return null; - - const senderJid = contextInfo.participant; - const senderE164 = senderJid ? jidToE164(senderJid) : null; - - return { - id: contextInfo.stanzaId || undefined, - body, - sender: senderE164 || senderJid || "unknown", - }; - } - - /** - * Store a message in conversation history. - */ - private storeMessageInHistory(chatJid: string, message: StoredMessage): void { - const history = this.conversationHistory.get(chatJid) || { - messages: [], - lastUpdated: Date.now(), - }; - - // Add message to history - history.messages.push(message); - history.lastUpdated = Date.now(); - - // Trim to max messages - while (history.messages.length > this.config.maxHistoryMessages) { - history.messages.shift(); - } - - this.conversationHistory.set(chatJid, history); - } - - /** - * Get conversation history for a chat. - * Returns messages in chronological order with role annotation. - */ - private getConversationHistory(chatJid: string): Array<{ - role: "user" | "assistant"; - content: string; - name?: string; - }> { - const history = this.conversationHistory.get(chatJid); - if (!history) return []; - - // Check TTL - const ttlMs = this.config.historyTtlSeconds * 1000; - if (Date.now() - history.lastUpdated > ttlMs) { - this.conversationHistory.delete(chatJid); - return []; - } - - return history.messages.map((msg) => ({ - role: msg.fromMe ? ("assistant" as const) : ("user" as const), - content: msg.text, - name: msg.senderName, - })); - } - - /** - * Store an outgoing (bot) message in history. - * Called from response renderer when sending messages. - */ - storeOutgoingMessage(chatJid: string, text: string): void { - this.storeMessageInHistory(chatJid, { - id: `outgoing_${Date.now()}`, - text, - fromMe: true, - timestamp: Date.now(), - }); - } - - /** - * Get conversation history for API endpoint. - * Returns messages formatted for the history API. - */ - getHistory( - chatJid: string, - limit: number, - before?: string - ): { - messages: Array<{ - timestamp: string; - user: string; - text: string; - isBot?: boolean; - }>; - nextCursor: string | null; - hasMore: boolean; - } { - const history = this.conversationHistory.get(chatJid); - if (!history) { - return { messages: [], nextCursor: null, hasMore: false }; - } - - // Check TTL - const ttlMs = this.config.historyTtlSeconds * 1000; - if (Date.now() - history.lastUpdated > ttlMs) { - this.conversationHistory.delete(chatJid); - return { messages: [], nextCursor: null, hasMore: false }; - } - - // Filter by before timestamp if provided - let messages = history.messages; - if (before) { - const beforeTs = new Date(before).getTime(); - messages = messages.filter((m) => m.timestamp < beforeTs); - } - - // Sort by timestamp descending (newest first) and limit - const sorted = [...messages] - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, limit); - - // Format for API response - const formatted = sorted.map((msg) => ({ - timestamp: new Date(msg.timestamp).toISOString(), - user: msg.senderName || (msg.fromMe ? "Assistant" : "User"), - text: msg.text, - isBot: msg.fromMe, - })); - - const hasMore = messages.length > limit; - const lastMessage = sorted[sorted.length - 1]; - const nextCursor = - hasMore && lastMessage - ? new Date(lastMessage.timestamp).toISOString() - : null; - - return { - messages: formatted, - nextCursor, - hasMore, - }; - } - - /** - * Cleanup expired conversation histories. - */ - private cleanupExpiredHistories(): void { - const now = Date.now(); - const ttlMs = this.config.historyTtlSeconds * 1000; - - for (const [chatJid, history] of this.conversationHistory) { - if (now - history.lastUpdated > ttlMs) { - this.conversationHistory.delete(chatJid); - } - } - } - - /** - * Handle message reactions. - * Could be used to trigger actions based on specific reactions. - */ - private async handleReactions( - reactions: BaileysEventMap["messages.reaction"] - ): Promise { - for (const reaction of reactions) { - const { key, reaction: reactionData } = reaction; - const emoji = reactionData.text; - const messageId = key.id; - const chatJid = key.remoteJid; - - logger.info( - { emoji, messageId, chatJid, from: key.participant }, - "Received reaction" - ); - - // Potential future use cases: - // - thumbs up on a tool approval message = approve - // - thumbs down = reject - // - checkmark = acknowledge - // For now, just log the reaction - } - } - - /** - * Handle message updates (edits, deletes). - * Could be used to update conversation history when messages are edited. - */ - private async handleMessageUpdates( - updates: BaileysEventMap["messages.update"] - ): Promise { - for (const update of updates) { - const { key, update: updateData } = update; - const messageId = key.id; - const chatJid = key.remoteJid; - - // Check if message was edited - if (updateData.message) { - logger.info( - { messageId, chatJid, hasNewMessage: true }, - "Message was edited" - ); - - // Could update the message in conversation history here - // For now, just log the event - } - - // Check if message was deleted (stub type indicates deletion) - if (updateData.messageStubType) { - logger.info( - { messageId, chatJid, stubType: updateData.messageStubType }, - "Message was deleted or has stub update" - ); - } - } - } + } + logger.warn( + { messageId, error: result.error }, + "Audio transcription failed or not configured", + ); + } + } + } + + // Build file metadata for payload + const fileMetadata = files.map((f) => ({ + id: f.id, + name: f.name, + mimetype: f.mimetype, + size: f.size, + })); + + // Fetch agent settings and merge with config defaults + const agentOptions = await this.getAgentOptionsWithSettings(agentId); + + const payload = buildMessagePayload({ + platform: "whatsapp", + userId: context.senderE164 || context.senderJid, + botId: "whatsapp", + conversationId, + teamId: context.isGroup ? context.chatJid : "whatsapp", + agentId, + messageId, + messageText: transcribedBody, + channelId: context.chatJid, + platformMetadata: { + traceId, + agentId, + jid: context.chatJid, + senderJid: context.senderJid, + senderE164: context.senderE164, + senderName: context.senderName, + isGroup: context.isGroup, + groupSubject: context.groupSubject, + quotedMessageId: context.quotedMessage?.id, + wasMentioned: context.wasMentioned, + responseChannel: context.chatJid, + responseId: messageId, + files: fileMetadata.length > 0 ? fileMetadata : undefined, + conversationHistory: + conversationHistory.length > 0 ? conversationHistory : undefined, + }, + agentOptions, + }); + + await this.queueProducer.enqueueMessage(payload); + logger.info( + { + traceId, + messageId, + conversationId, + chatJid: context.chatJid, + fileCount: files.length, + historyCount: conversationHistory.length, + }, + "Message enqueued", + ); + } + + /** + * Extract text from message. + */ + private extractText( + rawMessage: proto.IMessage | null | undefined, + ): string | undefined { + if (!rawMessage) { + logger.info("extractText: rawMessage is null/undefined"); + return undefined; + } + + logger.info( + `extractText: rawMessage keys = ${Object.keys(rawMessage).join(", ")}`, + ); + + const message = normalizeMessageContent(rawMessage); + if (!message) { + logger.info("extractText: normalizeMessageContent returned null"); + return undefined; + } + + logger.info( + `extractText: normalized message keys = ${Object.keys(message).join(", ")}`, + ); + + const extracted = extractMessageContent(message); + const candidates = [message, extracted !== message ? extracted : undefined]; + + for (const candidate of candidates) { + if (!candidate) continue; + + // Check conversation + if ( + typeof candidate.conversation === "string" && + candidate.conversation.trim() + ) { + return candidate.conversation.trim(); + } + + // Check extended text + const extended = candidate.extendedTextMessage?.text; + if (extended?.trim()) return extended.trim(); + + // Check captions + const caption = + candidate.imageMessage?.caption ?? + candidate.videoMessage?.caption ?? + candidate.documentMessage?.caption; + if (caption?.trim()) return caption.trim(); + } + + return undefined; + } + + /** + * Extract media placeholder text. + */ + private extractMediaPlaceholder( + rawMessage: proto.IMessage | null | undefined, + ): string | undefined { + if (!rawMessage) return undefined; + + const message = normalizeMessageContent(rawMessage); + if (!message) return undefined; + + if (message.imageMessage) return ""; + if (message.videoMessage) return ""; + if (message.audioMessage) return ""; + if (message.documentMessage) return ""; + if (message.stickerMessage) return ""; + + return undefined; + } + + /** + * Extract mentioned JIDs from message. + */ + private extractMentionedJids( + rawMessage: proto.IMessage | null | undefined, + ): string[] | undefined { + if (!rawMessage) return undefined; + + const message = normalizeMessageContent(rawMessage); + if (!message) return undefined; + + const candidates: Array = [ + message.extendedTextMessage?.contextInfo?.mentionedJid, + message.imageMessage?.contextInfo?.mentionedJid, + message.videoMessage?.contextInfo?.mentionedJid, + message.documentMessage?.contextInfo?.mentionedJid, + message.audioMessage?.contextInfo?.mentionedJid, + ]; + + const flattened = candidates.flatMap((arr) => arr ?? []).filter(Boolean); + if (flattened.length === 0) return undefined; + + return Array.from(new Set(flattened)); + } + + /** + * Extract reply context from message. + */ + private describeReplyContext( + rawMessage: proto.IMessage | null | undefined, + ): { id?: string; body: string; sender: string } | null { + if (!rawMessage) return null; + + const message = normalizeMessageContent(rawMessage); + if (!message) return null; + + // Get context info from various message types + const contextInfo = + message.extendedTextMessage?.contextInfo ?? + message.imageMessage?.contextInfo ?? + message.videoMessage?.contextInfo ?? + message.documentMessage?.contextInfo ?? + message.audioMessage?.contextInfo; + + if (!contextInfo?.quotedMessage) return null; + + const quoted = normalizeMessageContent(contextInfo.quotedMessage); + if (!quoted) return null; + + const body = + this.extractText(quoted) || this.extractMediaPlaceholder(quoted); + if (!body) return null; + + const senderJid = contextInfo.participant; + const senderE164 = senderJid ? jidToE164(senderJid) : null; + + return { + id: contextInfo.stanzaId || undefined, + body, + sender: senderE164 || senderJid || "unknown", + }; + } + + /** + * Store a message in conversation history. + */ + private storeMessageInHistory(chatJid: string, message: StoredMessage): void { + const history = this.conversationHistory.get(chatJid) || { + messages: [], + lastUpdated: Date.now(), + }; + + // Add message to history + history.messages.push(message); + history.lastUpdated = Date.now(); + + // Trim to max messages + while (history.messages.length > this.config.maxHistoryMessages) { + history.messages.shift(); + } + + this.conversationHistory.set(chatJid, history); + } + + /** + * Get conversation history for a chat. + * Returns messages in chronological order with role annotation. + */ + private getConversationHistory(chatJid: string): Array<{ + role: "user" | "assistant"; + content: string; + name?: string; + }> { + const history = this.conversationHistory.get(chatJid); + if (!history) return []; + + // Check TTL + const ttlMs = this.config.historyTtlSeconds * 1000; + if (Date.now() - history.lastUpdated > ttlMs) { + this.conversationHistory.delete(chatJid); + return []; + } + + return history.messages.map((msg) => ({ + role: msg.fromMe ? ("assistant" as const) : ("user" as const), + content: msg.text, + name: msg.senderName, + })); + } + + /** + * Store an outgoing (bot) message in history. + * Called from response renderer when sending messages. + */ + storeOutgoingMessage(chatJid: string, text: string): void { + this.storeMessageInHistory(chatJid, { + id: `outgoing_${Date.now()}`, + text, + fromMe: true, + timestamp: Date.now(), + }); + } + + /** + * Get conversation history for API endpoint. + * Returns messages formatted for the history API. + */ + getHistory( + chatJid: string, + limit: number, + before?: string, + ): { + messages: Array<{ + timestamp: string; + user: string; + text: string; + isBot?: boolean; + }>; + nextCursor: string | null; + hasMore: boolean; + } { + const history = this.conversationHistory.get(chatJid); + if (!history) { + return { messages: [], nextCursor: null, hasMore: false }; + } + + // Check TTL + const ttlMs = this.config.historyTtlSeconds * 1000; + if (Date.now() - history.lastUpdated > ttlMs) { + this.conversationHistory.delete(chatJid); + return { messages: [], nextCursor: null, hasMore: false }; + } + + // Filter by before timestamp if provided + let messages = history.messages; + if (before) { + const beforeTs = new Date(before).getTime(); + messages = messages.filter((m) => m.timestamp < beforeTs); + } + + // Sort by timestamp descending (newest first) and limit + const sorted = [...messages] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + + // Format for API response + const formatted = sorted.map((msg) => ({ + timestamp: new Date(msg.timestamp).toISOString(), + user: msg.senderName || (msg.fromMe ? "Assistant" : "User"), + text: msg.text, + isBot: msg.fromMe, + })); + + const hasMore = messages.length > limit; + const lastMessage = sorted[sorted.length - 1]; + const nextCursor = + hasMore && lastMessage + ? new Date(lastMessage.timestamp).toISOString() + : null; + + return { + messages: formatted, + nextCursor, + hasMore, + }; + } + + /** + * Cleanup expired conversation histories. + */ + private cleanupExpiredHistories(): void { + const now = Date.now(); + const ttlMs = this.config.historyTtlSeconds * 1000; + + for (const [chatJid, history] of this.conversationHistory) { + if (now - history.lastUpdated > ttlMs) { + this.conversationHistory.delete(chatJid); + } + } + } + + /** + * Handle message reactions. + * Could be used to trigger actions based on specific reactions. + */ + private async handleReactions( + reactions: BaileysEventMap["messages.reaction"], + ): Promise { + for (const reaction of reactions) { + const { key, reaction: reactionData } = reaction; + const emoji = reactionData.text; + const messageId = key.id; + const chatJid = key.remoteJid; + + logger.info( + { emoji, messageId, chatJid, from: key.participant }, + "Received reaction", + ); + + // Potential future use cases: + // - thumbs up on a tool approval message = approve + // - thumbs down = reject + // - checkmark = acknowledge + // For now, just log the reaction + } + } + + /** + * Handle message updates (edits, deletes). + * Could be used to update conversation history when messages are edited. + */ + private async handleMessageUpdates( + updates: BaileysEventMap["messages.update"], + ): Promise { + for (const update of updates) { + const { key, update: updateData } = update; + const messageId = key.id; + const chatJid = key.remoteJid; + + // Check if message was edited + if (updateData.message) { + logger.info( + { messageId, chatJid, hasNewMessage: true }, + "Message was edited", + ); + + // Could update the message in conversation history here + // For now, just log the event + } + + // Check if message was deleted (stub type indicates deletion) + if (updateData.messageStubType) { + logger.info( + { messageId, chatJid, stubType: updateData.messageStubType }, + "Message was deleted or has stub update", + ); + } + } + } } From 5132fce83b80e18f95b2b1c5bf99e720ef03c661 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 20:14:02 +0000 Subject: [PATCH 6/6] cleanup(auth): remove legacy token functions and update tests Delete generateSettingsToken, verifySettingsToken, buildSettingsUrl, SETTINGS_TOKEN_HASH_PARAM, SettingsTokenPayload alias, and SettingsTokenOptions from token-service.ts. Update all test files to use session-based auth (?s= query param) instead of encrypted tokens. https://claude.ai/code/session_01QhKDdik3bc5hkMecqJHFcq --- .../base-provider-module-auth.test.ts | 522 ++++++++++-------- .../src/__tests__/link-buttons.test.ts | 180 +++--- .../src/__tests__/token-service.test.ts | 312 ++++------- .../src/auth/settings/token-service.ts | 331 +++-------- 4 files changed, 544 insertions(+), 801 deletions(-) diff --git a/packages/gateway/src/__tests__/base-provider-module-auth.test.ts b/packages/gateway/src/__tests__/base-provider-module-auth.test.ts index f019c9f52..93917eafb 100644 --- a/packages/gateway/src/__tests__/base-provider-module-auth.test.ts +++ b/packages/gateway/src/__tests__/base-provider-module-auth.test.ts @@ -1,263 +1,303 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { - type BaseProviderConfig, - BaseProviderModule, + type BaseProviderConfig, + BaseProviderModule, } from "../auth/base-provider-module"; import { createAuthProfileLabel } from "../auth/settings/auth-profiles-manager"; -import { - generateChannelSettingsToken, - generateSettingsToken, - verifySettingsToken, -} from "../auth/settings/token-service"; - -const TEST_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); -if (!process.env.ENCRYPTION_KEY) { - process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; +import type { SettingsSessionPayload } from "../auth/settings/token-service"; + +/** + * In-memory session store for testing (replaces Redis-backed AuthSessionStore). + */ +class TestSessionStore { + private sessions = new Map(); + private nextId = 0; + + createSession( + payload: Omit, + ttlMs: number, + ): { sessionId: string; expiresAt: number } { + const sessionId = `test-session-${++this.nextId}`; + const expiresAt = Date.now() + ttlMs; + this.sessions.set(sessionId, { ...payload, exp: expiresAt }); + return { sessionId, expiresAt }; + } + + getSession(sessionId: string): SettingsSessionPayload | null { + const payload = this.sessions.get(sessionId); + if (!payload) return null; + if (Date.now() > payload.exp) { + this.sessions.delete(sessionId); + return null; + } + return payload; + } } class TestProviderModule extends BaseProviderModule { - constructor(authProfilesManager: { - upsertProfile(input: unknown): Promise; - deleteProviderProfiles( - agentId: string, - providerId: string, - profileId?: string - ): Promise; - hasProviderProfiles(agentId: string, providerId: string): Promise; - getBestProfile(agentId: string, providerId: string): Promise; - }) { - const config: BaseProviderConfig = { - providerId: "test-provider", - providerDisplayName: "Test Provider", - providerIconUrl: "https://example.com/icon.png", - credentialEnvVarName: "TEST_PROVIDER_API_KEY", - secretEnvVarNames: ["TEST_PROVIDER_API_KEY"], - authType: "api-key", - }; - - super(config, authProfilesManager as any); - } + constructor(authProfilesManager: { + upsertProfile(input: unknown): Promise; + deleteProviderProfiles( + agentId: string, + providerId: string, + profileId?: string, + ): Promise; + hasProviderProfiles(agentId: string, providerId: string): Promise; + getBestProfile(agentId: string, providerId: string): Promise; + }) { + const config: BaseProviderConfig = { + providerId: "test-provider", + providerDisplayName: "Test Provider", + providerIconUrl: "https://example.com/icon.png", + credentialEnvVarName: "TEST_PROVIDER_API_KEY", + secretEnvVarNames: ["TEST_PROVIDER_API_KEY"], + authType: "api-key", + }; + + super(config, authProfilesManager as any); + } } function createAuthProfilesManagerMock() { - const upsertCalls: unknown[] = []; - const deleteCalls: Array<{ - agentId: string; - providerId: string; - profileId?: string; - }> = []; - - const manager = { - async upsertProfile(input: unknown): Promise { - upsertCalls.push(input); - }, - async deleteProviderProfiles( - agentId: string, - providerId: string, - profileId?: string - ): Promise { - deleteCalls.push({ agentId, providerId, profileId }); - }, - async hasProviderProfiles(): Promise { - return false; - }, - async getBestProfile(): Promise { - return null; - }, - }; - - return { manager, upsertCalls, deleteCalls }; + const upsertCalls: unknown[] = []; + const deleteCalls: Array<{ + agentId: string; + providerId: string; + profileId?: string; + }> = []; + + const manager = { + async upsertProfile(input: unknown): Promise { + upsertCalls.push(input); + }, + async deleteProviderProfiles( + agentId: string, + providerId: string, + profileId?: string, + ): Promise { + deleteCalls.push({ agentId, providerId, profileId }); + }, + async hasProviderProfiles(): Promise { + return false; + }, + async getBestProfile(): Promise { + return null; + }, + }; + + return { manager, upsertCalls, deleteCalls }; } /** - * Build a mini auth router that mirrors the parameterized pattern + * Build a mini auth router that mirrors the session-based pattern * from gateway.ts (POST /:provider/save-key, POST /:provider/logout). + * Uses in-memory session store instead of Redis. */ function createAuthRouter( - providerModule: TestProviderModule, - authProfilesManager: ReturnType< - typeof createAuthProfilesManagerMock - >["manager"] + providerModule: TestProviderModule, + authProfilesManager: ReturnType< + typeof createAuthProfilesManagerMock + >["manager"], + sessionStore: TestSessionStore, ) { - const app = new Hono(); - const providerModuleMap = new Map([ - [providerModule.providerId, providerModule], - ]); - - app.post("/:provider/save-key", async (c) => { - try { - const providerId = c.req.param("provider"); - const mod = providerModuleMap.get(providerId); - if (!mod) return c.json({ error: "Unknown provider" }, 404); - - const body = await c.req.json(); - const { agentId, apiKey, token } = body; - if (!agentId || !apiKey) { - return c.json({ error: "Missing agentId or apiKey" }, 400); - } - - const queryToken = c.req.query("token"); - const authToken = typeof token === "string" ? token : queryToken; - const payload = authToken ? verifySettingsToken(authToken) : null; - if (!payload?.agentId || payload.agentId !== agentId) { - return c.json({ error: "Unauthorized" }, 401); - } - - await authProfilesManager.upsertProfile({ - agentId, - provider: providerId, - credential: apiKey, - authType: "api-key", - label: createAuthProfileLabel(mod.providerDisplayName, apiKey), - makePrimary: true, - }); - - return c.json({ success: true }); - } catch (_error) { - return c.json({ error: "Failed to save API key" }, 500); - } - }); - - app.post("/:provider/logout", async (c) => { - try { - const providerId = c.req.param("provider"); - const mod = providerModuleMap.get(providerId); - if (!mod) return c.json({ error: "Unknown provider" }, 404); - - const body = await c.req.json().catch(() => ({})); - const agentId = body.agentId || c.req.query("agentId"); - const queryToken = c.req.query("token"); - const authToken = - typeof body.token === "string" ? body.token : queryToken; - - if (!agentId) { - return c.json({ error: "Missing agentId" }, 400); - } - - const payload = authToken ? verifySettingsToken(authToken) : null; - if (!payload?.agentId || payload.agentId !== agentId) { - return c.json({ error: "Unauthorized" }, 401); - } - - await authProfilesManager.deleteProviderProfiles( - agentId, - providerId, - body.profileId - ); - - return c.json({ success: true }); - } catch (_error) { - return c.json({ error: "Failed to logout" }, 500); - } - }); - - return app; + const app = new Hono(); + const providerModuleMap = new Map([ + [providerModule.providerId, providerModule], + ]); + + const resolveSession = (c: any): SettingsSessionPayload | null => { + const sid = c.req.query("s"); + if (!sid) return null; + return sessionStore.getSession(sid); + }; + + app.post("/:provider/save-key", async (c) => { + try { + const providerId = c.req.param("provider"); + const mod = providerModuleMap.get(providerId); + if (!mod) return c.json({ error: "Unknown provider" }, 404); + + const body = await c.req.json(); + const { agentId, apiKey } = body; + if (!agentId || !apiKey) { + return c.json({ error: "Missing agentId or apiKey" }, 400); + } + + const payload = resolveSession(c); + if (!payload?.agentId || payload.agentId !== agentId) { + return c.json({ error: "Unauthorized" }, 401); + } + + await authProfilesManager.upsertProfile({ + agentId, + provider: providerId, + credential: apiKey, + authType: "api-key", + label: createAuthProfileLabel(mod.providerDisplayName, apiKey), + makePrimary: true, + }); + + return c.json({ success: true }); + } catch { + return c.json({ error: "Failed to save API key" }, 500); + } + }); + + app.post("/:provider/logout", async (c) => { + try { + const providerId = c.req.param("provider"); + const mod = providerModuleMap.get(providerId); + if (!mod) return c.json({ error: "Unknown provider" }, 404); + + const body = await c.req.json().catch(() => ({})); + const agentId = body.agentId || c.req.query("agentId"); + + if (!agentId) { + return c.json({ error: "Missing agentId" }, 400); + } + + const payload = resolveSession(c); + if (!payload?.agentId || payload.agentId !== agentId) { + return c.json({ error: "Unauthorized" }, 401); + } + + await authProfilesManager.deleteProviderProfiles( + agentId, + providerId, + body.profileId, + ); + + return c.json({ success: true }); + } catch { + return c.json({ error: "Failed to logout" }, 500); + } + }); + + return app; } describe("Auth router parameterized save-key/logout", () => { - test("rejects unauthenticated save-key requests", async () => { - const { manager, upsertCalls } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - - const response = await app.request("/test-provider/save-key", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), - }); - - expect(response.status).toBe(401); - expect(upsertCalls).toHaveLength(0); - }); - - test("rejects unauthenticated logout requests", async () => { - const { manager, deleteCalls } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - - const response = await app.request("/test-provider/logout", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1" }), - }); - - expect(response.status).toBe(401); - expect(deleteCalls).toHaveLength(0); - }); - - test("accepts authenticated save-key requests with matching agent token", async () => { - const { manager, upsertCalls } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - const token = generateSettingsToken("agent-1", "user-1", "slack"); - - const response = await app.request( - `/test-provider/save-key?token=${encodeURIComponent(token)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), - } - ); - - expect(response.status).toBe(200); - expect(upsertCalls).toHaveLength(1); - }); - - test("rejects authenticated save-key requests when token agent mismatches", async () => { - const { manager, upsertCalls } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - const token = generateSettingsToken("agent-2", "user-1", "slack"); - - const response = await app.request( - `/test-provider/save-key?token=${encodeURIComponent(token)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), - } - ); - - expect(response.status).toBe(401); - expect(upsertCalls).toHaveLength(0); - }); - - test("rejects channel-scoped token for save-key requests", async () => { - const { manager, upsertCalls } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - const token = generateChannelSettingsToken("user-1", "slack", "C123"); - - const response = await app.request( - `/test-provider/save-key?token=${encodeURIComponent(token)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), - } - ); - - expect(response.status).toBe(401); - expect(upsertCalls).toHaveLength(0); - }); - - test("returns 404 for unknown provider", async () => { - const { manager } = createAuthProfilesManagerMock(); - const module = new TestProviderModule(manager); - const app = createAuthRouter(module, manager); - const token = generateSettingsToken("agent-1", "user-1", "slack"); - - const response = await app.request( - `/unknown-provider/save-key?token=${encodeURIComponent(token)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), - } - ); - - expect(response.status).toBe(404); - }); + test("rejects unauthenticated save-key requests", async () => { + const { manager, upsertCalls } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + + const response = await app.request("/test-provider/save-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), + }); + + expect(response.status).toBe(401); + expect(upsertCalls).toHaveLength(0); + }); + + test("rejects unauthenticated logout requests", async () => { + const { manager, deleteCalls } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + + const response = await app.request("/test-provider/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1" }), + }); + + expect(response.status).toBe(401); + expect(deleteCalls).toHaveLength(0); + }); + + test("accepts authenticated save-key requests with matching agent session", async () => { + const { manager, upsertCalls } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + const { sessionId } = sessionStore.createSession( + { agentId: "agent-1", userId: "user-1", platform: "slack" }, + 60_000, + ); + + const response = await app.request( + `/test-provider/save-key?s=${encodeURIComponent(sessionId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), + }, + ); + + expect(response.status).toBe(200); + expect(upsertCalls).toHaveLength(1); + }); + + test("rejects authenticated save-key requests when session agent mismatches", async () => { + const { manager, upsertCalls } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + const { sessionId } = sessionStore.createSession( + { agentId: "agent-2", userId: "user-1", platform: "slack" }, + 60_000, + ); + + const response = await app.request( + `/test-provider/save-key?s=${encodeURIComponent(sessionId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), + }, + ); + + expect(response.status).toBe(401); + expect(upsertCalls).toHaveLength(0); + }); + + test("rejects channel-scoped session for save-key requests", async () => { + const { manager, upsertCalls } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + const { sessionId } = sessionStore.createSession( + { userId: "user-1", platform: "slack", channelId: "C123" }, + 60_000, + ); + + const response = await app.request( + `/test-provider/save-key?s=${encodeURIComponent(sessionId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), + }, + ); + + expect(response.status).toBe(401); + expect(upsertCalls).toHaveLength(0); + }); + + test("returns 404 for unknown provider", async () => { + const { manager } = createAuthProfilesManagerMock(); + const module = new TestProviderModule(manager); + const sessionStore = new TestSessionStore(); + const app = createAuthRouter(module, manager, sessionStore); + const { sessionId } = sessionStore.createSession( + { agentId: "agent-1", userId: "user-1", platform: "slack" }, + 60_000, + ); + + const response = await app.request( + `/unknown-provider/save-key?s=${encodeURIComponent(sessionId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId: "agent-1", apiKey: "sk-test" }), + }, + ); + + expect(response.status).toBe(404); + }); }); diff --git a/packages/gateway/src/__tests__/link-buttons.test.ts b/packages/gateway/src/__tests__/link-buttons.test.ts index cb1cbcd52..d72e37e9b 100644 --- a/packages/gateway/src/__tests__/link-buttons.test.ts +++ b/packages/gateway/src/__tests__/link-buttons.test.ts @@ -2,99 +2,89 @@ import { describe, expect, test } from "bun:test"; import { extractSettingsLinkButtons } from "../platform/link-buttons"; describe("extractSettingsLinkButtons", () => { - test("extracts settings link and replaces with label", () => { - const content = - "Click [Open Settings](https://example.com/settings#st=abc123) to continue"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(1); - expect(linkButtons[0]!.text).toBe("Open Settings"); - expect(linkButtons[0]!.url).toBe("https://example.com/settings#st=abc123"); - expect(processedContent).toBe("Click Open Settings to continue"); - expect(processedContent).not.toContain("https://"); - }); - - test("extracts settings link with query param (?st=)", () => { - const content = "[Settings](https://example.com/settings?st=token123)"; - const { linkButtons } = extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(1); - expect(linkButtons[0]!.url).toBe( - "https://example.com/settings?st=token123" - ); - }); - - test("extracts multiple settings links", () => { - const content = - "[First](https://a.com/settings#st=1) and [Second](https://b.com/settings#st=2)"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(2); - expect(processedContent).toBe("First and Second"); - }); - - test("filters out localhost URLs", () => { - const content = "[Settings](http://localhost:3000/settings#st=token)"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(0); - // Label still replaces the link - expect(processedContent).toBe("Settings"); - }); - - test("filters out 127.0.0.1 URLs", () => { - const content = "[Settings](http://127.0.0.1/settings#st=token)"; - const { linkButtons } = extractSettingsLinkButtons(content); - expect(linkButtons).toHaveLength(0); - }); - - test("does not match non-settings links", () => { - const content = "[Home](https://example.com/home)"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(0); - expect(processedContent).toBe(content); // unchanged - }); - - test("does not match links without st= parameter", () => { - const content = "[Settings](https://example.com/settings)"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(0); - expect(processedContent).toBe(content); - }); - - test("returns empty buttons for content without links", () => { - const content = "No links here, just plain text"; - const { processedContent, linkButtons } = - extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(0); - expect(processedContent).toBe(content); - }); - - test("handles HTTP and HTTPS", () => { - const httpContent = "[A](http://example.com/settings#st=x)"; - const httpsContent = "[B](https://example.com/settings#st=y)"; - - const httpResult = extractSettingsLinkButtons(httpContent); - const httpsResult = extractSettingsLinkButtons(httpsContent); - - expect(httpResult.linkButtons).toHaveLength(1); - expect(httpsResult.linkButtons).toHaveLength(1); - }); - - test("mixed localhost and remote links only keeps remote", () => { - const content = - "[Local](http://localhost/settings#st=a) and [Remote](https://app.com/settings#st=b)"; - const { linkButtons } = extractSettingsLinkButtons(content); - - expect(linkButtons).toHaveLength(1); - expect(linkButtons[0]!.url).toContain("app.com"); - }); + test("extracts settings link and replaces with label", () => { + const content = + "Click [Open Settings](https://example.com/settings?s=abc123) to continue"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(1); + expect(linkButtons[0]!.text).toBe("Open Settings"); + expect(linkButtons[0]!.url).toBe("https://example.com/settings?s=abc123"); + expect(processedContent).toBe("Click Open Settings to continue"); + expect(processedContent).not.toContain("https://"); + }); + + test("extracts multiple settings links", () => { + const content = + "[First](https://a.com/settings?s=1) and [Second](https://b.com/settings?s=2)"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(2); + expect(processedContent).toBe("First and Second"); + }); + + test("filters out localhost URLs", () => { + const content = "[Settings](http://localhost:3000/settings?s=token)"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(0); + // Label still replaces the link + expect(processedContent).toBe("Settings"); + }); + + test("filters out 127.0.0.1 URLs", () => { + const content = "[Settings](http://127.0.0.1/settings?s=token)"; + const { linkButtons } = extractSettingsLinkButtons(content); + expect(linkButtons).toHaveLength(0); + }); + + test("does not match non-settings links", () => { + const content = "[Home](https://example.com/home)"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(0); + expect(processedContent).toBe(content); // unchanged + }); + + test("does not match links without s= parameter", () => { + const content = "[Settings](https://example.com/settings)"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(0); + expect(processedContent).toBe(content); + }); + + test("returns empty buttons for content without links", () => { + const content = "No links here, just plain text"; + const { processedContent, linkButtons } = + extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(0); + expect(processedContent).toBe(content); + }); + + test("handles HTTP and HTTPS", () => { + const httpContent = "[A](http://example.com/settings?s=x)"; + const httpsContent = "[B](https://example.com/settings?s=y)"; + + const httpResult = extractSettingsLinkButtons(httpContent); + const httpsResult = extractSettingsLinkButtons(httpsContent); + + expect(httpResult.linkButtons).toHaveLength(1); + expect(httpsResult.linkButtons).toHaveLength(1); + }); + + test("mixed localhost and remote links only keeps remote", () => { + const content = + "[Local](http://localhost/settings?s=a) and [Remote](https://app.com/settings?s=b)"; + const { linkButtons } = extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(1); + expect(linkButtons[0]!.url).toContain("app.com"); + }); }); diff --git a/packages/gateway/src/__tests__/token-service.test.ts b/packages/gateway/src/__tests__/token-service.test.ts index bc5b38faf..f12a84c70 100644 --- a/packages/gateway/src/__tests__/token-service.test.ts +++ b/packages/gateway/src/__tests__/token-service.test.ts @@ -1,228 +1,108 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { - buildSettingsUrl, - buildTelegramSettingsUrl, - formatSettingsTokenTtl, - generateChannelSettingsToken, - generateSettingsToken, - getSettingsTokenTtlMs, - verifySettingsToken, + buildTelegramSettingsUrl, + formatSettingsTokenTtl, + getSettingsTokenTtlMs, } from "../auth/settings/token-service"; describe("getSettingsTokenTtlMs", () => { - let originalTtl: string | undefined; - - beforeEach(() => { - originalTtl = process.env.SETTINGS_TOKEN_TTL_MS; - }); - - afterEach(() => { - if (originalTtl !== undefined) { - process.env.SETTINGS_TOKEN_TTL_MS = originalTtl; - } else { - delete process.env.SETTINGS_TOKEN_TTL_MS; - } - }); - - test("returns default 1 hour when env not set", () => { - delete process.env.SETTINGS_TOKEN_TTL_MS; - expect(getSettingsTokenTtlMs()).toBe(3600000); - }); - - test("returns default for empty string", () => { - process.env.SETTINGS_TOKEN_TTL_MS = ""; - expect(getSettingsTokenTtlMs()).toBe(3600000); - }); - - test("returns default for invalid value", () => { - process.env.SETTINGS_TOKEN_TTL_MS = "not-a-number"; - expect(getSettingsTokenTtlMs()).toBe(3600000); - }); - - test("returns default for negative value", () => { - process.env.SETTINGS_TOKEN_TTL_MS = "-1000"; - expect(getSettingsTokenTtlMs()).toBe(3600000); - }); - - test("returns parsed value for valid number", () => { - process.env.SETTINGS_TOKEN_TTL_MS = "7200000"; - expect(getSettingsTokenTtlMs()).toBe(7200000); - }); + let originalTtl: string | undefined; + + beforeEach(() => { + originalTtl = process.env.SETTINGS_TOKEN_TTL_MS; + }); + + afterEach(() => { + if (originalTtl !== undefined) { + process.env.SETTINGS_TOKEN_TTL_MS = originalTtl; + } else { + delete process.env.SETTINGS_TOKEN_TTL_MS; + } + }); + + test("returns default 1 hour when env not set", () => { + delete process.env.SETTINGS_TOKEN_TTL_MS; + expect(getSettingsTokenTtlMs()).toBe(3600000); + }); + + test("returns default for empty string", () => { + process.env.SETTINGS_TOKEN_TTL_MS = ""; + expect(getSettingsTokenTtlMs()).toBe(3600000); + }); + + test("returns default for invalid value", () => { + process.env.SETTINGS_TOKEN_TTL_MS = "not-a-number"; + expect(getSettingsTokenTtlMs()).toBe(3600000); + }); + + test("returns default for negative value", () => { + process.env.SETTINGS_TOKEN_TTL_MS = "-1000"; + expect(getSettingsTokenTtlMs()).toBe(3600000); + }); + + test("returns parsed value for valid number", () => { + process.env.SETTINGS_TOKEN_TTL_MS = "7200000"; + expect(getSettingsTokenTtlMs()).toBe(7200000); + }); }); describe("formatSettingsTokenTtl", () => { - test("formats weeks", () => { - const week = 7 * 24 * 60 * 60 * 1000; - expect(formatSettingsTokenTtl(week)).toBe("1 week"); - expect(formatSettingsTokenTtl(2 * week)).toBe("2 weeks"); - }); - - test("formats days", () => { - const day = 24 * 60 * 60 * 1000; - expect(formatSettingsTokenTtl(day)).toBe("1 day"); - expect(formatSettingsTokenTtl(3 * day)).toBe("3 days"); - }); - - test("formats hours", () => { - const hour = 60 * 60 * 1000; - expect(formatSettingsTokenTtl(hour)).toBe("1 hour"); - expect(formatSettingsTokenTtl(2 * hour)).toBe("2 hours"); - }); - - test("formats minutes", () => { - const minute = 60 * 1000; - expect(formatSettingsTokenTtl(minute)).toBe("1 minute"); - expect(formatSettingsTokenTtl(5 * minute)).toBe("5 minutes"); - }); - - test("formats seconds as fallback", () => { - expect(formatSettingsTokenTtl(1000)).toBe("1 second"); - expect(formatSettingsTokenTtl(30000)).toBe("30 seconds"); - }); -}); - -describe("generateSettingsToken / verifySettingsToken", () => { - let originalKey: string | undefined; - - beforeEach(() => { - originalKey = process.env.ENCRYPTION_KEY; - process.env.ENCRYPTION_KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - }); - - afterEach(() => { - if (originalKey !== undefined) { - process.env.ENCRYPTION_KEY = originalKey; - } else { - delete process.env.ENCRYPTION_KEY; - } - }); - - test("generates token that can be verified", () => { - const token = generateSettingsToken("agent-1", "user-1", "slack"); - const payload = verifySettingsToken(token); - expect(payload).not.toBeNull(); - expect(payload!.agentId).toBe("agent-1"); - expect(payload!.userId).toBe("user-1"); - expect(payload!.platform).toBe("slack"); - }); - - test("channel-based token round-trip", () => { - const token = generateChannelSettingsToken( - "user-1", - "telegram", - "C123", - "T456" - ); - const payload = verifySettingsToken(token); - expect(payload).not.toBeNull(); - expect(payload!.agentId).toBeUndefined(); - expect(payload!.channelId).toBe("C123"); - expect(payload!.teamId).toBe("T456"); - }); - - test("throws when neither agentId nor channelId", () => { - expect(() => generateSettingsToken(undefined, "user-1", "slack")).toThrow( - "requires at least one of agentId or channelId" - ); - }); - - test("includes optional fields when provided", () => { - const token = generateSettingsToken("agent-1", "user-1", "slack", { - message: "Setup your key", - prefillEnvVars: ["API_KEY"], - prefillGrants: ["*.openai.com"], - }); - const payload = verifySettingsToken(token); - expect(payload!.message).toBe("Setup your key"); - expect(payload!.prefillEnvVars).toEqual(["API_KEY"]); - expect(payload!.prefillGrants).toEqual(["*.openai.com"]); - }); - - test("backwards compat: number as options is ttlMs", () => { - const token = generateSettingsToken("agent-1", "user-1", "slack", 5000); - const payload = verifySettingsToken(token); - expect(payload).not.toBeNull(); - // Token should expire very soon (5 seconds) - expect(payload!.exp).toBeLessThan(Date.now() + 6000); - }); - - test("returns null for expired token", async () => { - // Generate token with very short TTL - const token = generateSettingsToken("agent-1", "user-1", "slack", 1); - // Wait for it to expire - await new Promise((r) => setTimeout(r, 10)); - const payload = verifySettingsToken(token); - expect(payload).toBeNull(); - }); - - test("returns null for garbage token", () => { - expect(verifySettingsToken("not-a-valid-token")).toBeNull(); - }); -}); - -describe("buildSettingsUrl", () => { - let originalGateway: string | undefined; - - beforeEach(() => { - originalGateway = process.env.PUBLIC_GATEWAY_URL; - }); - - afterEach(() => { - if (originalGateway !== undefined) { - process.env.PUBLIC_GATEWAY_URL = originalGateway; - } else { - delete process.env.PUBLIC_GATEWAY_URL; - } - }); - - test("uses PUBLIC_GATEWAY_URL when set", () => { - process.env.PUBLIC_GATEWAY_URL = "https://app.example.com"; - const url = buildSettingsUrl("mytoken"); - expect(url).toStartWith("https://app.example.com/settings#st="); - }); - - test("defaults to localhost:8080", () => { - delete process.env.PUBLIC_GATEWAY_URL; - const url = buildSettingsUrl("mytoken"); - expect(url).toStartWith("http://localhost:8080/settings#st="); - }); - - test("encodes token in hash fragment", () => { - delete process.env.PUBLIC_GATEWAY_URL; - const url = buildSettingsUrl("tok/en+with=special"); - expect(url).toContain("#st="); - // Token should be URI-encoded - expect(url).toContain(encodeURIComponent("tok/en+with=special")); - }); + test("formats weeks", () => { + const week = 7 * 24 * 60 * 60 * 1000; + expect(formatSettingsTokenTtl(week)).toBe("1 week"); + expect(formatSettingsTokenTtl(2 * week)).toBe("2 weeks"); + }); + + test("formats days", () => { + const day = 24 * 60 * 60 * 1000; + expect(formatSettingsTokenTtl(day)).toBe("1 day"); + expect(formatSettingsTokenTtl(3 * day)).toBe("3 days"); + }); + + test("formats hours", () => { + const hour = 60 * 60 * 1000; + expect(formatSettingsTokenTtl(hour)).toBe("1 hour"); + expect(formatSettingsTokenTtl(2 * hour)).toBe("2 hours"); + }); + + test("formats minutes", () => { + const minute = 60 * 1000; + expect(formatSettingsTokenTtl(minute)).toBe("1 minute"); + expect(formatSettingsTokenTtl(5 * minute)).toBe("5 minutes"); + }); + + test("formats seconds as fallback", () => { + expect(formatSettingsTokenTtl(1000)).toBe("1 second"); + expect(formatSettingsTokenTtl(30000)).toBe("30 seconds"); + }); }); describe("buildTelegramSettingsUrl", () => { - let originalGateway: string | undefined; - - beforeEach(() => { - originalGateway = process.env.PUBLIC_GATEWAY_URL; - }); - - afterEach(() => { - if (originalGateway !== undefined) { - process.env.PUBLIC_GATEWAY_URL = originalGateway; - } else { - delete process.env.PUBLIC_GATEWAY_URL; - } - }); - - test("builds URL with platform and chat params", () => { - process.env.PUBLIC_GATEWAY_URL = "https://app.example.com"; - const url = buildTelegramSettingsUrl("12345"); - expect(url).toBe( - "https://app.example.com/settings?platform=telegram&chat=12345" - ); - }); - - test("encodes chatId", () => { - delete process.env.PUBLIC_GATEWAY_URL; - const url = buildTelegramSettingsUrl("chat with spaces"); - expect(url).toContain("chat=chat%20with%20spaces"); - }); + let originalGateway: string | undefined; + + beforeEach(() => { + originalGateway = process.env.PUBLIC_GATEWAY_URL; + }); + + afterEach(() => { + if (originalGateway !== undefined) { + process.env.PUBLIC_GATEWAY_URL = originalGateway; + } else { + delete process.env.PUBLIC_GATEWAY_URL; + } + }); + + test("builds URL with platform and chat params", () => { + process.env.PUBLIC_GATEWAY_URL = "https://app.example.com"; + const url = buildTelegramSettingsUrl("12345"); + expect(url).toBe( + "https://app.example.com/settings?platform=telegram&chat=12345", + ); + }); + + test("encodes chatId", () => { + delete process.env.PUBLIC_GATEWAY_URL; + const url = buildTelegramSettingsUrl("chat with spaces"); + expect(url).toContain("chat=chat%20with%20spaces"); + }); }); diff --git a/packages/gateway/src/auth/settings/token-service.ts b/packages/gateway/src/auth/settings/token-service.ts index c00869968..188af8074 100644 --- a/packages/gateway/src/auth/settings/token-service.ts +++ b/packages/gateway/src/auth/settings/token-service.ts @@ -1,4 +1,4 @@ -import { createLogger, decrypt, encrypt } from "@lobu/core"; +import { createLogger } from "@lobu/core"; const logger = createLogger("settings-token-service"); @@ -6,32 +6,32 @@ const logger = createLogger("settings-token-service"); * Pre-filled skill configuration for settings page */ export interface PrefillSkill { - /** Skill repository (e.g., "anthropics/skills/pdf") */ - repo: string; - /** Display name */ - name?: string; - /** Description */ - description?: string; + /** Skill repository (e.g., "anthropics/skills/pdf") */ + repo: string; + /** Display name */ + name?: string; + /** Description */ + description?: string; } /** * Pre-filled MCP server configuration for settings page */ export interface PrefillMcpServer { - /** MCP server ID (key in mcpServers record) */ - id: string; - /** Display name/description */ - name?: string; - /** Server URL (for SSE type) */ - url?: string; - /** Server type */ - type?: "sse" | "stdio"; - /** Command (for stdio type) */ - command?: string; - /** Args (for stdio type) */ - args?: string[]; - /** Environment variables needed (just the keys, user fills values) */ - envVars?: string[]; + /** MCP server ID (key in mcpServers record) */ + id: string; + /** Display name/description */ + name?: string; + /** Server URL (for SSE type) */ + url?: string; + /** Server type */ + type?: "sse" | "stdio"; + /** Command (for stdio type) */ + command?: string; + /** Args (for stdio type) */ + args?: string[]; + /** Environment variables needed (just the keys, user fills values) */ + envVars?: string[]; } /** @@ -39,53 +39,46 @@ export interface PrefillMcpServer { * Used to send follow-up notifications back to the same conversation. */ export interface SettingsSourceContext { - conversationId: string; - channelId: string; - teamId?: string; - platform?: string; + conversationId: string; + channelId: string; + teamId?: string; + platform?: string; } /** - * Payload stored in the settings token. + * Canonical session payload type. Stored server-side in Redis. * * Supports two entry modes: * - Agent-based: `agentId` is set (from /configure, Slack Home tab, worker endpoint) * - Channel-based: `channelId` is set, `agentId` may be absent (from message handlers when no agent bound) * At least one of `agentId` or `channelId` must be present. */ -/** - * Canonical session payload type. Stored server-side in Redis. - * SettingsTokenPayload is kept as an alias for backward compatibility. - */ export interface SettingsSessionPayload { - /** Agent to configure. Optional when using channel-based entry (user picks agent on page). */ - agentId?: string; - userId: string; - platform: string; - exp: number; // Expiration timestamp (ms) - /** Channel that triggered the settings link. Used for agent switching and binding. */ - channelId?: string; - /** Team/workspace ID for multi-tenant platforms (Slack). */ - teamId?: string; - /** Optional message to display on the settings page (e.g., instructions to get an API key) */ - message?: string; - /** Optional env vars to pre-fill in the settings page (just the keys, user fills values) */ - prefillEnvVars?: string[]; - /** Optional skills to pre-fill (user confirms to enable) */ - prefillSkills?: PrefillSkill[]; - /** Optional MCP servers to pre-fill (user confirms to enable) */ - prefillMcpServers?: PrefillMcpServer[]; - /** Optional Nix packages to pre-fill in the system packages section */ - prefillNixPackages?: string[]; - /** Optional domain patterns to pre-fill as grants */ - prefillGrants?: string[]; - /** Optional source context for post-install notifications */ - sourceContext?: SettingsSourceContext; + /** Agent to configure. Optional when using channel-based entry (user picks agent on page). */ + agentId?: string; + userId: string; + platform: string; + exp: number; // Expiration timestamp (ms) + /** Channel that triggered the settings link. Used for agent switching and binding. */ + channelId?: string; + /** Team/workspace ID for multi-tenant platforms (Slack). */ + teamId?: string; + /** Optional message to display on the settings page (e.g., instructions to get an API key) */ + message?: string; + /** Optional env vars to pre-fill in the settings page (just the keys, user fills values) */ + prefillEnvVars?: string[]; + /** Optional skills to pre-fill (user confirms to enable) */ + prefillSkills?: PrefillSkill[]; + /** Optional MCP servers to pre-fill (user confirms to enable) */ + prefillMcpServers?: PrefillMcpServer[]; + /** Optional Nix packages to pre-fill in the system packages section */ + prefillNixPackages?: string[]; + /** Optional domain patterns to pre-fill as grants */ + prefillGrants?: string[]; + /** Optional source context for post-install notifications */ + sourceContext?: SettingsSourceContext; } -/** @deprecated Use SettingsSessionPayload instead. */ -export type SettingsTokenPayload = SettingsSessionPayload; - /** * Default TTL for settings tokens (1 hour) */ @@ -97,7 +90,7 @@ const MINUTE_MS = 60 * 1000; const SECOND_MS = 1000; function formatUnit(value: number, unit: string): string { - return `${value} ${unit}${value === 1 ? "" : "s"}`; + return `${value} ${unit}${value === 1 ? "" : "s"}`; } /** @@ -106,203 +99,43 @@ function formatUnit(value: number, unit: string): string { * `SETTINGS_TOKEN_TTL_MS` is optional. If not set (or invalid), falls back to 1 hour. */ export function getSettingsTokenTtlMs(): number { - const raw = process.env.SETTINGS_TOKEN_TTL_MS; - if (!raw || raw.trim().length === 0) { - return DEFAULT_TOKEN_TTL_MS; - } - - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - logger.warn( - { rawValue: raw }, - "Invalid SETTINGS_TOKEN_TTL_MS; using default 1 hour" - ); - return DEFAULT_TOKEN_TTL_MS; - } - - return parsed; + const raw = process.env.SETTINGS_TOKEN_TTL_MS; + if (!raw || raw.trim().length === 0) { + return DEFAULT_TOKEN_TTL_MS; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + logger.warn( + { rawValue: raw }, + "Invalid SETTINGS_TOKEN_TTL_MS; using default 1 hour", + ); + return DEFAULT_TOKEN_TTL_MS; + } + + return parsed; } /** * Human-readable settings token TTL for user-facing messages. */ export function formatSettingsTokenTtl( - ttlMs = getSettingsTokenTtlMs() -): string { - if (ttlMs >= WEEK_MS && ttlMs % WEEK_MS === 0) { - return formatUnit(ttlMs / WEEK_MS, "week"); - } - if (ttlMs >= DAY_MS && ttlMs % DAY_MS === 0) { - return formatUnit(ttlMs / DAY_MS, "day"); - } - if (ttlMs >= HOUR_MS && ttlMs % HOUR_MS === 0) { - return formatUnit(ttlMs / HOUR_MS, "hour"); - } - if (ttlMs >= MINUTE_MS && ttlMs % MINUTE_MS === 0) { - return formatUnit(ttlMs / MINUTE_MS, "minute"); - } - const seconds = Math.max(1, Math.round(ttlMs / SECOND_MS)); - return formatUnit(seconds, "second"); -} - -/** - * Options for generating settings tokens - */ -export interface SettingsTokenOptions { - /** TTL in milliseconds (default: 1 hour) */ - ttlMs?: number; - /** Channel ID for channel-based entry (agent picker mode) */ - channelId?: string; - /** Team/workspace ID for multi-tenant platforms */ - teamId?: string; - /** Optional message to display on the settings page */ - message?: string; - /** Optional env var keys to pre-fill (user fills the values) */ - prefillEnvVars?: string[]; - /** Optional skills to pre-fill (user confirms to enable) */ - prefillSkills?: PrefillSkill[]; - /** Optional MCP servers to pre-fill (user confirms to enable) */ - prefillMcpServers?: PrefillMcpServer[]; - /** Optional Nix packages to pre-fill in system packages section */ - prefillNixPackages?: string[]; - /** Optional domain patterns to pre-fill as grants */ - prefillGrants?: string[]; - /** Optional source context for post-install notifications */ - sourceContext?: SettingsSourceContext; -} - -/** - * Generate a magic link token for accessing settings page. - * - * Supports two modes: - * - Agent-based: agentId provided, goes directly to settings - * - Channel-based: agentId omitted, channelId in options, shows agent picker - * - * At least one of agentId or options.channelId must be provided. - */ -export function generateSettingsToken( - agentId: string | undefined, - userId: string, - platform: string, - options: SettingsTokenOptions | number = {} -): string { - // Handle backwards compatibility: if options is a number, treat as ttlMs - const opts: SettingsTokenOptions = - typeof options === "number" ? { ttlMs: options } : options; - const ttlMs = opts.ttlMs ?? getSettingsTokenTtlMs(); - - if (!agentId && !opts.channelId) { - throw new Error( - "generateSettingsToken requires at least one of agentId or channelId" - ); - } - - const payload: SettingsSessionPayload = { - userId, - platform, - exp: Date.now() + ttlMs, - ...(agentId && { agentId }), - ...(opts.channelId && { channelId: opts.channelId }), - ...(opts.teamId && { teamId: opts.teamId }), - ...(opts.message && { message: opts.message }), - ...(opts.prefillEnvVars?.length && { prefillEnvVars: opts.prefillEnvVars }), - ...(opts.prefillSkills?.length && { prefillSkills: opts.prefillSkills }), - ...(opts.prefillMcpServers?.length && { - prefillMcpServers: opts.prefillMcpServers, - }), - ...(opts.prefillNixPackages?.length && { - prefillNixPackages: opts.prefillNixPackages, - }), - ...(opts.prefillGrants?.length && { - prefillGrants: opts.prefillGrants, - }), - ...(opts.sourceContext && { sourceContext: opts.sourceContext }), - }; - - const encrypted = encrypt(JSON.stringify(payload)); - logger.info( - `Generated settings token for ${agentId ? `agent ${agentId}` : `channel ${opts.channelId}`}, user ${userId}` - ); - return encrypted; -} - -/** - * Generate a channel-based settings token (no agentId). - * Used by message handlers when no agent is bound to a channel. - */ -export function generateChannelSettingsToken( - userId: string, - platform: string, - channelId: string, - teamId?: string -): string { - return generateSettingsToken(undefined, userId, platform, { - channelId, - teamId, - }); -} - -/** - * Verify and decode a settings token - * - * Returns the payload if valid and not expired, null otherwise. - * Requires at least one of agentId or channelId. - */ -export function verifySettingsToken( - token: string -): SettingsTokenPayload | null { - try { - const decrypted = decrypt(token); - const payload = JSON.parse(decrypted) as SettingsTokenPayload; - - // Validate required fields: userId, platform, exp, and at least one of agentId/channelId - if (!payload.userId || !payload.platform || !payload.exp) { - logger.warn("Invalid settings token: missing required fields"); - return null; - } - if (!payload.agentId && !payload.channelId) { - logger.warn( - "Invalid settings token: must have at least one of agentId or channelId" - ); - return null; - } - - // Check expiration - if (Date.now() > payload.exp) { - logger.warn( - `Settings token expired for ${payload.agentId ? `agent ${payload.agentId}` : `channel ${payload.channelId}`}` - ); - return null; - } - - logger.debug( - `Verified settings token for ${payload.agentId ? `agent ${payload.agentId}` : `channel ${payload.channelId}`}` - ); - return payload; - } catch (error) { - logger.warn("Failed to verify settings token", { error }); - return null; - } -} - -/** - * Build the full settings URL with token - */ -export const SETTINGS_TOKEN_HASH_PARAM = "st"; - -export function buildSettingsUrl( - token: string, - opts?: { useQueryParam?: boolean } + ttlMs = getSettingsTokenTtlMs(), ): string { - const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; - // Telegram web_app buttons replace the URL hash with tgWebAppData, so use a - // query parameter instead. The server's legacy ?token= handler validates the - // token, sets a session cookie, and redirects to /settings (clearing the URL). - if (opts?.useQueryParam) { - return `${baseUrl}/settings?token=${encodeURIComponent(token)}`; - } - // Keep the token in URL hash so it never appears in server logs/referrers. - return `${baseUrl}/settings#${SETTINGS_TOKEN_HASH_PARAM}=${encodeURIComponent(token)}`; + if (ttlMs >= WEEK_MS && ttlMs % WEEK_MS === 0) { + return formatUnit(ttlMs / WEEK_MS, "week"); + } + if (ttlMs >= DAY_MS && ttlMs % DAY_MS === 0) { + return formatUnit(ttlMs / DAY_MS, "day"); + } + if (ttlMs >= HOUR_MS && ttlMs % HOUR_MS === 0) { + return formatUnit(ttlMs / HOUR_MS, "hour"); + } + if (ttlMs >= MINUTE_MS && ttlMs % MINUTE_MS === 0) { + return formatUnit(ttlMs / MINUTE_MS, "minute"); + } + const seconds = Math.max(1, Math.round(ttlMs / SECOND_MS)); + return formatUnit(seconds, "second"); } /** @@ -312,6 +145,6 @@ export function buildSettingsUrl( * so the URL never expires and can be reused across button taps. */ export function buildTelegramSettingsUrl(chatId: string): string { - const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; - return `${baseUrl}/settings?platform=telegram&chat=${encodeURIComponent(chatId)}`; + const baseUrl = process.env.PUBLIC_GATEWAY_URL || "http://localhost:8080"; + return `${baseUrl}/settings?platform=telegram&chat=${encodeURIComponent(chatId)}`; }