Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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<ReturnType<typeof createTestOrganization>>;
let templateAgent: Awaited<ReturnType<typeof createTestAgent>>;
let user: Awaited<ReturnType<typeof createTestUser>>;
let personalOrg: Awaited<ReturnType<typeof createTestOrganization>>;

beforeAll(async () => {
await cleanupTestDatabase();
const sql = getTestDb();

templateOrg = await createTestOrganization({
name: 'PF Template',
slug: 'personal-finance-tpl',
visibility: 'public',
});
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 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', {
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}`;
});
});
99 changes: 99 additions & 0 deletions packages/owletto-backend/src/agents/install-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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 { getRateLimiter, RateLimitPresets } from '../utils/rate-limiter';
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();
// 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::jsonb)->>'personal_org_for_user_id' = ${userId}
ORDER BY "createdAt" ASC, id ASC
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);

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();
} 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 };
7 changes: 7 additions & 0 deletions packages/owletto-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions packages/owletto-backend/src/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading