From 60f876a856e2a8d17d12aaf382137eeb7ef41db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 25 Apr 2026 03:57:35 +0100 Subject: [PATCH] feat(agents): install-token + chat-claim primitives for the WhatsApp deep-link flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the backend half of the per-user-org WhatsApp install: a short-lived HMAC-signed token, a way to mint it, and a way to claim it when an inbound chat message arrives carrying the token. New: - install-token.ts: mintInstallToken(userId, templateAgentId, ttl) and verifyInstallToken(message). Format `install:.`. HMAC-SHA256 over a domain-separated key derived from ENCRYPTION_KEY. 15-minute TTL. Stateless — no DB row. - install-token-routes.ts: POST /api/install/token. Signed-in user posts {templateAgentId}, gets back {token, expiresInSeconds}. The landing page builds the wa.me/?text=install: link client-side. - installed-agent-lookup.ts: - findInstalledAgentByIdentity(platform, platformUserId, templateAgentId) — single SQL hop from inbound JID/Slack-id to the agent instance in the user's personal org via entity_identities. - claimInstallFromChat(message, platform, platformUserId) — verifies an `install:` message and runs the install end-to-end: resolves the user's personal org, calls installAgentFromTemplate, and links the platform identity (e.g. wa_jid + phone) onto the user's $member. Tests: - 7 unit tests for mint/verify round-trip, HMAC tamper detection, expiry, malformed input, secret rotation. What's NOT in this PR: - The gateway-side wiring that calls these helpers from message-handler-bridge. That's a separate PR — it touches the hot-path resolveAgentId / commandDispatcher pipeline and deserves focused review. The two helpers above are the building blocks; the gateway PR will add a CoreServices method that injects them and a branch in the message handler that: 1. If text matches `install:`, call claimInstallFromChat and reply with success / install URL. 2. Otherwise, if connection has templateAgentId, call findInstalledAgentByIdentity. Hit → route to that agent. Miss → reply with the install URL. --- .../agents/__tests__/install-token.test.ts | 98 +++++++++++ .../src/agents/install-token-routes.ts | 54 ++++++ .../src/agents/install-token.ts | 115 +++++++++++++ .../src/agents/installed-agent-lookup.ts | 159 ++++++++++++++++++ packages/owletto-backend/src/index.ts | 3 + 5 files changed, 429 insertions(+) create mode 100644 packages/owletto-backend/src/agents/__tests__/install-token.test.ts create mode 100644 packages/owletto-backend/src/agents/install-token-routes.ts create mode 100644 packages/owletto-backend/src/agents/install-token.ts create mode 100644 packages/owletto-backend/src/agents/installed-agent-lookup.ts diff --git a/packages/owletto-backend/src/agents/__tests__/install-token.test.ts b/packages/owletto-backend/src/agents/__tests__/install-token.test.ts new file mode 100644 index 000000000..0476df907 --- /dev/null +++ b/packages/owletto-backend/src/agents/__tests__/install-token.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + isInstallTokenMessage, + mintInstallToken, + verifyInstallToken, +} from '../install-token'; + +const ORIGINAL_KEY = process.env.ENCRYPTION_KEY; + +describe('install-token', () => { + beforeAll(() => { + process.env.ENCRYPTION_KEY = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + }); + afterAll(() => { + if (ORIGINAL_KEY === undefined) delete process.env.ENCRYPTION_KEY; + else process.env.ENCRYPTION_KEY = ORIGINAL_KEY; + }); + + describe('mint + verify round-trip', () => { + it('verifies a freshly minted token', () => { + const token = mintInstallToken({ + userId: 'user_abc', + templateAgentId: 'agent_xyz', + }); + expect(token.startsWith('install:')).toBe(true); + + const result = verifyInstallToken(token); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.userId).toBe('user_abc'); + expect(result.templateAgentId).toBe('agent_xyz'); + expect(result.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + } + }); + + it('detects tampered payload via HMAC mismatch', () => { + const token = mintInstallToken({ + userId: 'user_abc', + templateAgentId: 'agent_xyz', + }); + const [head, sig] = token.slice('install:'.length).split('.'); + const tamperedPayload = Buffer.from( + JSON.stringify({ u: 'attacker', t: 'agent_xyz', e: Math.floor(Date.now() / 1000) + 600 }) + ).toString('base64url'); + const tampered = `install:${tamperedPayload}.${sig}`; + const result = verifyInstallToken(tampered); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('bad_signature'); + expect(head).toBeTruthy(); + }); + + it('rejects expired tokens', () => { + const token = mintInstallToken({ + userId: 'user_abc', + templateAgentId: 'agent_xyz', + ttlSeconds: -1, + }); + const result = verifyInstallToken(token); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('expired'); + }); + + it('rejects malformed tokens', () => { + expect(verifyInstallToken('not a token').ok).toBe(false); + expect(verifyInstallToken('install:').ok).toBe(false); + expect(verifyInstallToken('install:abc').ok).toBe(false); + expect(verifyInstallToken('install:abc.def').ok).toBe(false); + }); + + it('rejects tokens minted with a different secret', () => { + const token = mintInstallToken({ + userId: 'user_abc', + templateAgentId: 'agent_xyz', + }); + const oldKey = process.env.ENCRYPTION_KEY; + process.env.ENCRYPTION_KEY = + 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210'; + const result = verifyInstallToken(token); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('bad_signature'); + process.env.ENCRYPTION_KEY = oldKey; + }); + }); + + describe('isInstallTokenMessage', () => { + it('accepts the canonical prefix', () => { + expect(isInstallTokenMessage('install:abc.def')).toBe(true); + expect(isInstallTokenMessage(' install:abc.def')).toBe(true); + }); + + it('rejects normal chat messages', () => { + expect(isInstallTokenMessage('hello')).toBe(false); + expect(isInstallTokenMessage('install me please')).toBe(false); + expect(isInstallTokenMessage('install: foo')).toBe(true); // a malformed token still claims to be one + }); + }); +}); diff --git a/packages/owletto-backend/src/agents/install-token-routes.ts b/packages/owletto-backend/src/agents/install-token-routes.ts new file mode 100644 index 000000000..1ac9d0eef --- /dev/null +++ b/packages/owletto-backend/src/agents/install-token-routes.ts @@ -0,0 +1,54 @@ +/** + * POST /api/install/token + * Mints a short-lived install token for a signed-in user. The landing page + * embeds it in a `https://wa.me/?text=install:` link so the + * user can claim the install via WhatsApp without re-typing their phone + * number. + */ + +import { type Context, Hono } from 'hono'; +import { requireAuth } from '../auth/middleware'; +import type { Env } from '../index'; +import { errorMessage } from '../utils/errors'; +import { mintInstallToken } from './install-token'; + +const installTokenRoutes = new Hono<{ Bindings: Env }>(); + +function getAuthenticatedUser(c: Context<{ Bindings: Env }>) { + const user = c.get('user'); + if (!user) throw new Error('Authenticated user missing from context'); + return user; +} + +installTokenRoutes.post('/install/token', requireAuth, async (c) => { + const user = getAuthenticatedUser(c); + + let body: { templateAgentId?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + if (!body.templateAgentId || typeof body.templateAgentId !== 'string') { + return c.json({ error: 'templateAgentId is required' }, 400); + } + + try { + const token = mintInstallToken({ + userId: user.id, + templateAgentId: body.templateAgentId, + }); + return c.json({ + token, + expiresInSeconds: 15 * 60, + // wa.me link is constructed by the caller — they know the bot's phone + // number from the connection config (or hard-coded in the landing + // page). We don't ship phone numbers from this endpoint. + }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 500); + } +}); + +export { installTokenRoutes }; diff --git a/packages/owletto-backend/src/agents/install-token.ts b/packages/owletto-backend/src/agents/install-token.ts new file mode 100644 index 000000000..81af26820 --- /dev/null +++ b/packages/owletto-backend/src/agents/install-token.ts @@ -0,0 +1,115 @@ +/** + * Install tokens — short-lived HMAC-signed claims that authorize installing + * a specific template agent into a specific user's personal org via a + * non-web channel (e.g. inbound WhatsApp message containing the token). + * + * Format: + * install:. + * Payload (JSON): { u: userId, t: templateAgentId, e: expiryEpochSeconds } + * + * Stateless — no DB row. Re-using a token within the expiry window is fine + * (idempotent install). Once the token expires the user gets a fresh one + * from the landing page. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const TOKEN_PREFIX = 'install:'; +const TOKEN_TTL_SECONDS = 15 * 60; + +interface TokenPayload { + u: string; // userId + t: string; // templateAgentId + e: number; // expiry, epoch seconds +} + +function getSecret(): Buffer { + const raw = process.env.ENCRYPTION_KEY; + if (!raw) { + throw new Error('ENCRYPTION_KEY is required to mint install tokens'); + } + // Domain-separate from the encryption key by hashing once with a label. + return createHmac('sha256', raw).update('install-token:v1').digest(); +} + +function base64UrlEncode(buf: Buffer | string): string { + const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf; + return b.toString('base64url'); +} + +function base64UrlDecode(s: string): Buffer { + return Buffer.from(s, 'base64url'); +} + +export function mintInstallToken(params: { + userId: string; + templateAgentId: string; + ttlSeconds?: number; +}): string { + const ttl = params.ttlSeconds ?? TOKEN_TTL_SECONDS; + const payload: TokenPayload = { + u: params.userId, + t: params.templateAgentId, + e: Math.floor(Date.now() / 1000) + ttl, + }; + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)); + const sig = createHmac('sha256', getSecret()).update(payloadEncoded).digest(); + return `${TOKEN_PREFIX}${payloadEncoded}.${base64UrlEncode(sig)}`; +} + +interface VerifyOk { + ok: true; + userId: string; + templateAgentId: string; + expiresAt: number; +} +interface VerifyErr { + ok: false; + error: 'malformed' | 'bad_signature' | 'expired'; +} + +export function verifyInstallToken(input: string): VerifyOk | VerifyErr { + if (!input.startsWith(TOKEN_PREFIX)) return { ok: false, error: 'malformed' }; + const body = input.slice(TOKEN_PREFIX.length).trim(); + const [payloadEncoded, sigEncoded] = body.split('.'); + if (!payloadEncoded || !sigEncoded) return { ok: false, error: 'malformed' }; + + const expected = createHmac('sha256', getSecret()).update(payloadEncoded).digest(); + let provided: Buffer; + try { + provided = base64UrlDecode(sigEncoded); + } catch { + return { ok: false, error: 'malformed' }; + } + if (provided.length !== expected.length) return { ok: false, error: 'bad_signature' }; + if (!timingSafeEqual(provided, expected)) return { ok: false, error: 'bad_signature' }; + + let payload: TokenPayload; + try { + payload = JSON.parse(base64UrlDecode(payloadEncoded).toString('utf8')) as TokenPayload; + } catch { + return { ok: false, error: 'malformed' }; + } + if ( + typeof payload.u !== 'string' || + typeof payload.t !== 'string' || + typeof payload.e !== 'number' + ) { + return { ok: false, error: 'malformed' }; + } + + if (Math.floor(Date.now() / 1000) >= payload.e) { + return { ok: false, error: 'expired' }; + } + return { + ok: true, + userId: payload.u, + templateAgentId: payload.t, + expiresAt: payload.e, + }; +} + +/** True when an inbound chat message looks like an install token claim. */ +export function isInstallTokenMessage(text: string): boolean { + return text.trim().startsWith(TOKEN_PREFIX); +} diff --git a/packages/owletto-backend/src/agents/installed-agent-lookup.ts b/packages/owletto-backend/src/agents/installed-agent-lookup.ts new file mode 100644 index 000000000..7272fea2f --- /dev/null +++ b/packages/owletto-backend/src/agents/installed-agent-lookup.ts @@ -0,0 +1,159 @@ +/** + * Cross-package lookup that resolves an inbound platform identity (e.g. + * a WhatsApp JID) to the installed-agent instance the gateway should route + * messages to. Backed by the same Postgres the rest of owletto-backend uses; + * the gateway gets it via CoreServices. + * + * The data path: + * wa_jid → entity_identities → $member entity → its organization → + * the agent in that org with template_agent_id matching + */ + +import { getDb } from '../db/client'; +import { installAgentFromTemplate } from './install'; +import { linkWhatsAppToMember } from '../auth/subject-identities'; +import { isInstallTokenMessage, verifyInstallToken } from './install-token'; + +export interface InstalledAgentLocation { + agentId: string; + organizationId: string; +} + +/** + * Find the agent instance that should handle messages from `(platform, userId)` + * for the given template. Returns null when the platform user hasn't yet + * installed the template agent in their personal org. + */ +export async function findInstalledAgentByIdentity(params: { + platform: string; + platformUserId: string; + templateAgentId: string; +}): Promise { + const sql = getDb(); + const namespace = identityNamespaceForPlatform(params.platform); + if (!namespace) return null; + + // Single query: from the inbound identifier, walk to the $member entity, + // then to its organization, then to the agent installed in that org with + // matching template_agent_id. + const rows = await sql` + SELECT a.id AS agent_id, a.organization_id + FROM entity_identities ei + JOIN entities m ON m.id = ei.entity_id + AND m.entity_type = '$member' + AND m.deleted_at IS NULL + JOIN agents a ON a.organization_id = ei.organization_id + AND a.template_agent_id = ${params.templateAgentId} + WHERE ei.namespace = ${namespace} + AND ei.identifier = ${params.platformUserId} + AND ei.deleted_at IS NULL + LIMIT 1 + `; + if (rows.length === 0) return null; + return { + agentId: rows[0].agent_id as string, + organizationId: rows[0].organization_id as string, + }; +} + +function identityNamespaceForPlatform(platform: string): string | null { + switch (platform.toLowerCase()) { + case 'whatsapp': + return 'wa_jid'; + case 'slack': + return 'slack_user_id'; + case 'telegram': + return 'telegram_user_id'; + default: + return null; + } +} + +/** + * Look up the email of the user who owns this Lobu user_id, used to bridge + * back into linkWhatsAppToMember (which keys on email — same as ensureMemberEntity). + */ +async function getUserEmail(userId: string): Promise { + const sql = getDb(); + const rows = await sql`SELECT email FROM "user" WHERE id = ${userId} LIMIT 1`; + if (rows.length === 0) return null; + return rows[0].email as string; +} + +interface ClaimResult { + status: 'installed' | 'token_invalid' | 'token_expired' | 'no_personal_org' | 'no_member'; + agentId?: string; + organizationId?: string; + reason?: string; +} + +/** + * Process an inbound `install:` message from a chat platform. + * Validates the token, completes the install in the user's personal org, + * and links the platform identity (e.g. wa_jid) to their $member. + * + * Returns a structured result the gateway can render back as a chat reply. + */ +export async function claimInstallFromChat(params: { + message: string; + platform: string; + platformUserId: string; +}): Promise { + if (!isInstallTokenMessage(params.message)) { + return { status: 'token_invalid', reason: 'Message is not an install token' }; + } + const verified = verifyInstallToken(params.message.trim()); + if (!verified.ok) { + return verified.error === 'expired' + ? { status: 'token_expired' } + : { status: 'token_invalid', reason: verified.error }; + } + + const sql = getDb(); + const orgRows = await sql` + SELECT id, slug FROM "organization" + WHERE metadata IS NOT NULL + AND metadata LIKE ${`%"personal_org_for_user_id":"${verified.userId}"%`} + ORDER BY "createdAt" ASC, id ASC + LIMIT 1 + `; + if (orgRows.length === 0) { + return { status: 'no_personal_org' }; + } + const personalOrgId = orgRows[0].id as string; + + const installResult = await installAgentFromTemplate({ + templateAgentId: verified.templateAgentId, + targetOrganizationId: personalOrgId, + userId: verified.userId, + }); + + // Link the platform identity. linkWhatsAppToMember keys on email, so we + // need it from the user row. + if (params.platform.toLowerCase() === 'whatsapp') { + const email = await getUserEmail(verified.userId); + if (!email) return { status: 'no_member' }; + const linked = await linkWhatsAppToMember({ + organizationId: personalOrgId, + email, + // The platformUserId here is already a JID like "447...@s.whatsapp.net". + // linkWhatsAppToMember normalizes from a phone string, so reverse-derive. + rawPhone: jidToPhone(params.platformUserId), + }); + if ('error' in linked) { + return { status: 'no_member', reason: linked.error }; + } + } + + return { + status: 'installed', + agentId: installResult.agentId, + organizationId: installResult.organizationId, + }; +} + +function jidToPhone(jid: string): string { + const at = jid.indexOf('@'); + const digits = at >= 0 ? jid.slice(0, at) : jid; + return `+${digits}`; +} diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index fb0c3fbca..537260c5f 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -24,6 +24,7 @@ import { getDb } from './db/client'; import * as invalidationEmitter from './events/emitter'; import { isExcludedSpaPath } from './http/spa-route-filter'; import { installRoutes } from './agents/install-routes'; +import { installTokenRoutes } from './agents/install-token-routes'; import { agentRoutes } from './lobu/agent-routes'; import { clientRoutes, platformSchemaRoutes } from './lobu/client-routes'; import { isLobuGatewayRunning } from './lobu/gateway'; @@ -441,8 +442,10 @@ app.route('/api', credentialRoutes); /** * Template agent installation routes * POST /api/install — install a template agent into the caller's personal org + * POST /api/install/token — mint an install token for chat-side claim */ app.route('/api', installRoutes); +app.route('/api', installTokenRoutes); /** * OAuth 2.1 Authorization Server routes