From 12be72f292e130e044f712cf48f5a447051f4bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 25 Apr 2026 03:00:16 +0100 Subject: [PATCH 1/3] feat(agents): public install endpoint for template agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/install takes { templateAgentId } from a signed-in user and: 1. Resolves the user's personal organization via the personal_org_for_user_id metadata tag written by the user.create.after hook. 2. Runs installAgentFromTemplate(), which creates the agent instance in the user's org and mirrors the template's entity_types + entity_relationship_types with managed_by_template_agent_id set. 3. Returns the new agent id, the target org slug, and a redirectTo path — enough for the upcoming /install/:slug landing page to post and redirect without any follow-up API calls. Error paths: - Missing templateAgentId → 400 - Signed-in user has no personal org (hook failed / pre-hook user) → 409 with error="no_personal_org" - installAgentFromTemplate failure (template not found, self-install, user-authored collision) → 400 with the upstream error message. Integration test stubs the auth middleware and exercises the happy path, the missing-field 400, and the missing-personal-org 409. Pairs with the upcoming /install/personal-finance landing page in the owletto-web submodule, which ships as a separate two-PR pair. --- .../integration/agents/install-routes.test.ts | 123 ++++++++++++++++++ .../src/agents/install-routes.ts | 85 ++++++++++++ packages/owletto-backend/src/index.ts | 7 + 3 files changed, 215 insertions(+) create mode 100644 packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts create mode 100644 packages/owletto-backend/src/agents/install-routes.ts diff --git a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts new file mode 100644 index 000000000..dee579c35 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts @@ -0,0 +1,123 @@ +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { installRoutes } from '../../../agents/install-routes'; +import type { Env } from '../../../index'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestAgent, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; + +/** + * Mounts the install routes with a stubbed `user` context (bypassing the real + * requireAuth middleware, which needs a full Better Auth session). This + * exercises the route handler's behavior — personal-org lookup, delegation to + * installAgentFromTemplate, error surfacing — without reimplementing auth in + * the test harness. + */ +function buildApp(userId: string): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.use('*', async (c, next) => { + c.set('user', { + id: userId, + name: 'Test', + email: 'test@example.com', + emailVerified: true, + }); + await next(); + }); + app.route('/api', installRoutes); + return app; +} + +describe('POST /api/install', () => { + let templateOrg: Awaited>; + let templateAgent: Awaited>; + let user: Awaited>; + let personalOrg: Awaited>; + + beforeAll(async () => { + await cleanupTestDatabase(); + const sql = getTestDb(); + + templateOrg = await createTestOrganization({ + name: 'PF Template', + slug: 'personal-finance-tpl', + }); + templateAgent = await createTestAgent({ + organizationId: templateOrg.id, + name: 'Personal Finance', + }); + await sql` + INSERT INTO entity_types (slug, name, description, metadata_schema, organization_id, created_by) + VALUES ('transaction', 'Transaction', 'A debit/credit', '{"type":"object"}'::jsonb, ${templateOrg.id}, 'system') + `; + + user = await createTestUser(); + personalOrg = await createTestOrganization({ + name: 'User Personal Org', + slug: `personal-${user.id.slice(5, 13)}`, + }); + // Mirrors what the user.create.after hook writes — the install endpoint + // relies on this tag to resolve the caller's personal org. + await sql` + UPDATE "organization" + SET metadata = ${JSON.stringify({ personal_org_for_user_id: user.id })} + WHERE id = ${personalOrg.id} + `; + await addUserToOrganization(user.id, personalOrg.id, 'owner'); + }); + + it('installs the template into the caller personal org and returns redirect info', async () => { + const app = buildApp(user.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ templateAgentId: templateAgent.agentId }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + agentId: string; + organizationId: string; + organizationSlug: string; + created: boolean; + mirrored: { entity_types: number }; + redirectTo: string; + }; + expect(body.organizationId).toBe(personalOrg.id); + expect(body.organizationSlug).toBe(personalOrg.slug); + expect(body.created).toBe(true); + expect(body.mirrored.entity_types).toBe(1); + expect(body.redirectTo).toBe(`/${personalOrg.slug}/agents/${body.agentId}`); + }); + + it('rejects requests without templateAgentId', async () => { + const app = buildApp(user.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it('returns 409 when the caller has no personal org', async () => { + const sql = getTestDb(); + const userWithoutOrg = await createTestUser(); + // Intentionally no personal org provisioned for this user. + const app = buildApp(userWithoutOrg.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ templateAgentId: templateAgent.agentId }), + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('no_personal_org'); + + // Don't leak the orphan user into subsequent tests. + await sql`DELETE FROM "user" WHERE id = ${userWithoutOrg.id}`; + }); +}); diff --git a/packages/owletto-backend/src/agents/install-routes.ts b/packages/owletto-backend/src/agents/install-routes.ts new file mode 100644 index 000000000..8dc0435de --- /dev/null +++ b/packages/owletto-backend/src/agents/install-routes.ts @@ -0,0 +1,85 @@ +/** + * Public install endpoints backing the /install/:slug landing pages. + * + * A signed-in user POSTs { templateAgentId } and we mirror that template into + * the user's personal org (looked up via the personal_org_for_user_id tag we + * set in the user.create.after hook). Returns the new agent id so the landing + * page can redirect to /$org/agents/$id. + */ + +import { type Context, Hono } from 'hono'; +import { requireAuth } from '../auth/middleware'; +import { getDb } from '../db/client'; +import type { Env } from '../index'; +import { errorMessage } from '../utils/errors'; +import { installAgentFromTemplate } from './install'; + +const installRoutes = 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; +} + +async function resolvePersonalOrg( + userId: string +): Promise<{ id: string; slug: string } | null> { + const sql = getDb(); + const tagFragment = `"personal_org_for_user_id":"${userId}"`; + const rows = await sql` + SELECT id, slug FROM "organization" + WHERE metadata IS NOT NULL AND metadata LIKE ${`%${tagFragment}%`} + LIMIT 1 + `; + if (rows.length === 0) return null; + return { id: rows[0].id as string, slug: rows[0].slug as string }; +} + +installRoutes.post('/install', requireAuth, async (c) => { + const user = getAuthenticatedUser(c); + + let body: { templateAgentId?: string; name?: 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); + } + + const personalOrg = await resolvePersonalOrg(user.id); + if (!personalOrg) { + return c.json( + { + error: 'no_personal_org', + message: + 'No personal organization found for this user. Sign out and back in, or create one manually, then retry.', + }, + 409 + ); + } + + try { + const result = await installAgentFromTemplate({ + templateAgentId: body.templateAgentId, + targetOrganizationId: personalOrg.id, + userId: user.id, + name: body.name, + }); + return c.json({ + agentId: result.agentId, + organizationId: result.organizationId, + organizationSlug: personalOrg.slug, + created: result.created, + mirrored: result.mirrored, + redirectTo: `/${personalOrg.slug}/agents/${result.agentId}`, + }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } +}); + +export { installRoutes }; diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index 1302c5221..fb0c3fbca 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -23,6 +23,7 @@ import { connectRoutes } from './connect/routes'; 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 { agentRoutes } from './lobu/agent-routes'; import { clientRoutes, platformSchemaRoutes } from './lobu/client-routes'; import { isLobuGatewayRunning } from './lobu/gateway'; @@ -437,6 +438,12 @@ app.on(['GET', 'POST'], '/api/auth/*', async (c) => { */ app.route('/api', credentialRoutes); +/** + * Template agent installation routes + * POST /api/install — install a template agent into the caller's personal org + */ +app.route('/api', installRoutes); + /** * OAuth 2.1 Authorization Server routes * Provides MCP authentication for HTTP clients (Claude.ai, ChatGPT) From 2d1546407f946e6dc3da6f00c52788d5f196ffee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 25 Apr 2026 03:35:36 +0100 Subject: [PATCH 2/3] fix(agents): restrict public installs --- .../integration/agents/install-routes.test.ts | 18 ++++++++++++++++++ .../src/agents/install-routes.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts index dee579c35..8caf8dbd1 100644 --- a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts +++ b/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts @@ -45,6 +45,7 @@ describe('POST /api/install', () => { templateOrg = await createTestOrganization({ name: 'PF Template', slug: 'personal-finance-tpl', + visibility: 'public', }); templateAgent = await createTestAgent({ organizationId: templateOrg.id, @@ -93,6 +94,23 @@ describe('POST /api/install', () => { expect(body.redirectTo).toBe(`/${personalOrg.slug}/agents/${body.agentId}`); }); + it('rejects private template agents', async () => { + const privateOrg = await createTestOrganization({ name: 'Private Template' }); + const privateAgent = await createTestAgent({ + organizationId: privateOrg.id, + name: 'Private Agent', + }); + const app = buildApp(user.id); + const res = await app.request('/api/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ templateAgentId: privateAgent.agentId }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/organization is not public/); + }); + it('rejects requests without templateAgentId', async () => { const app = buildApp(user.id); const res = await app.request('/api/install', { diff --git a/packages/owletto-backend/src/agents/install-routes.ts b/packages/owletto-backend/src/agents/install-routes.ts index 8dc0435de..02b0cbfa3 100644 --- a/packages/owletto-backend/src/agents/install-routes.ts +++ b/packages/owletto-backend/src/agents/install-routes.ts @@ -30,6 +30,7 @@ async function resolvePersonalOrg( const rows = await sql` SELECT id, slug FROM "organization" WHERE metadata IS NOT NULL AND metadata LIKE ${`%${tagFragment}%`} + ORDER BY "createdAt" ASC, id ASC LIMIT 1 `; if (rows.length === 0) return null; From 42d58da9c0e3c4a9bf054c428c4666c3eb942741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 25 Apr 2026 15:28:02 +0100 Subject: [PATCH 3/3] fix(agents): harden public install endpoint - Replace metadata LIKE search with `(metadata::jsonb)->>'personal_org_for_user_id' = $1` so a userId containing % or _ can't match unintended rows. - Add per-user hourly rate limit (20/hour) via the standard rate-limiter preset to prevent abusive install loops. --- .../src/agents/install-routes.ts | 17 +++++++++++++++-- .../owletto-backend/src/utils/rate-limiter.ts | 7 +++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/owletto-backend/src/agents/install-routes.ts b/packages/owletto-backend/src/agents/install-routes.ts index 02b0cbfa3..bdf206000 100644 --- a/packages/owletto-backend/src/agents/install-routes.ts +++ b/packages/owletto-backend/src/agents/install-routes.ts @@ -12,6 +12,7 @@ import { requireAuth } from '../auth/middleware'; import { getDb } from '../db/client'; import type { Env } from '../index'; import { errorMessage } from '../utils/errors'; +import { getRateLimiter, RateLimitPresets } from '../utils/rate-limiter'; import { installAgentFromTemplate } from './install'; const installRoutes = new Hono<{ Bindings: Env }>(); @@ -26,10 +27,13 @@ async function resolvePersonalOrg( userId: string ): Promise<{ id: string; slug: string } | null> { const sql = getDb(); - const tagFragment = `"personal_org_for_user_id":"${userId}"`; + // organization.metadata is `text` storing JSON; cast to jsonb and use the + // ->> operator instead of LIKE so a userId containing % or _ can't match + // unintended rows. const rows = await sql` SELECT id, slug FROM "organization" - WHERE metadata IS NOT NULL AND metadata LIKE ${`%${tagFragment}%`} + WHERE metadata IS NOT NULL + AND (metadata::jsonb)->>'personal_org_for_user_id' = ${userId} ORDER BY "createdAt" ASC, id ASC LIMIT 1 `; @@ -40,6 +44,15 @@ async function resolvePersonalOrg( installRoutes.post('/install', requireAuth, async (c) => { const user = getAuthenticatedUser(c); + const rateLimiter = getRateLimiter(); + const rateLimit = rateLimiter.checkLimit( + `rate:install-agent:${user.id}`, + RateLimitPresets.INSTALL_AGENT_PER_USER_HOUR + ); + if (!rateLimit.allowed) { + return c.json({ error: rateLimit.errorMessage }, 429); + } + let body: { templateAgentId?: string; name?: string }; try { body = await c.req.json(); diff --git a/packages/owletto-backend/src/utils/rate-limiter.ts b/packages/owletto-backend/src/utils/rate-limiter.ts index 92044341a..90381cb02 100644 --- a/packages/owletto-backend/src/utils/rate-limiter.ts +++ b/packages/owletto-backend/src/utils/rate-limiter.ts @@ -179,6 +179,13 @@ export const RateLimitPresets = { windowSeconds: 3600, errorMessage: 'Join rate limit exceeded. Maximum 10 join attempts per hour.', } as RateLimitConfig, + + /** Public template-agent install: 20/hour per user */ + INSTALL_AGENT_PER_USER_HOUR: { + limit: 20, + windowSeconds: 3600, + errorMessage: 'Install rate limit exceeded. Maximum 20 installs per hour.', + } as RateLimitConfig, }; /** Module-level singleton rate limiter. */