From 0d09235f5c64c49d37880af41341095cb9701c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sun, 26 Apr 2026 22:33:14 +0100 Subject: [PATCH] revert(install-flow): remove template-install pipeline (#369, #357, #362, #359 install-half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install flow as built — schema-mirror clones a template's entity types / relationship types / classifiers / watchers into each user's personal org — was the wrong abstraction. Cross-org vocabulary (an entity in tenant org A referencing a type defined in a public-catalog org B by FK) is the planned direction; the mirror pipeline duplicated rows per user and added re-sync complexity for no working installs (verified 0 rows used the mirror columns in prod). Removed: - packages/owletto-backend/src/agents/install.ts (installAgentFromTemplate, resyncInstalledAgent) - packages/owletto-backend/src/agents/install-routes.ts (POST /api/install) - packages/owletto-backend/src/agents/install-manifest-routes.ts (GET /api/install/manifest/:slug) - All associated integration tests - subject-identities WhatsApp helpers (normalizePhoneE164, phoneToWhatsAppJid, linkWhatsAppToMember) + their unit tests - db/migrations/20260425120000_add_template_mirror_tracking.sql (rolled back on prod first) - Route registrations from src/index.ts Kept: - subject-identities.ts provisionMemberAndCoreIdentities — used by the signup hook in personal-org-provisioning.ts, orthogonal to install flow. - #352 personal-org-on-signup, #350/#354/#355/#356 personal-finance content — no install dependencies. DB state: prod migrated down via dbmate (mirror columns dropped), then 20260426120000_entities_entity_type_fk re-applied. 0 user-visible data lost. --- ...425120000_add_template_mirror_tracking.sql | 69 -- db/schema.sql | 43 +- .../agents/install-manifest-routes.test.ts | 86 -- .../integration/agents/install-routes.test.ts | 211 ---- .../integration/agents/install.test.ts | 361 ------- .../src/agents/install-manifest-routes.ts | 82 -- .../src/agents/install-routes.ts | 161 ---- .../owletto-backend/src/agents/install.ts | 900 ------------------ .../auth/__tests__/subject-identities.test.ts | 39 - .../src/auth/subject-identities.ts | 69 +- packages/owletto-backend/src/index.ts | 10 - 11 files changed, 6 insertions(+), 2025 deletions(-) delete mode 100644 db/migrations/20260425120000_add_template_mirror_tracking.sql delete mode 100644 packages/owletto-backend/src/__tests__/integration/agents/install-manifest-routes.test.ts delete mode 100644 packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts delete mode 100644 packages/owletto-backend/src/__tests__/integration/agents/install.test.ts delete mode 100644 packages/owletto-backend/src/agents/install-manifest-routes.ts delete mode 100644 packages/owletto-backend/src/agents/install-routes.ts delete mode 100644 packages/owletto-backend/src/agents/install.ts delete mode 100644 packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts diff --git a/db/migrations/20260425120000_add_template_mirror_tracking.sql b/db/migrations/20260425120000_add_template_mirror_tracking.sql deleted file mode 100644 index cabde264b..000000000 --- a/db/migrations/20260425120000_add_template_mirror_tracking.sql +++ /dev/null @@ -1,69 +0,0 @@ --- migrate:up - --- The schema-mirror feature lets a template agent (e.g. examples/personal-finance) --- own a canonical set of entity types, relationship types, classifiers and --- watcher definitions in its template org and mirror them into each user's --- personal org when they install the agent. The two new columns record the --- provenance of mirrored rows so they can be re-synced on template updates and --- treated as read-only by the user-org owner. --- --- managed_by_template_agent_id: the agents.id (in the template org) that owns --- this row. NULL for rows authored by the user themselves. --- source_template_org_id: the organization.id of the template org. Pairs with --- the agent_id so a user can re-sync against the right source even if the --- template agent is later renamed. - -ALTER TABLE public.entity_types - ADD COLUMN managed_by_template_agent_id text, - ADD COLUMN source_template_org_id text; - -ALTER TABLE public.entity_relationship_types - ADD COLUMN managed_by_template_agent_id text, - ADD COLUMN source_template_org_id text; - -ALTER TABLE public.event_classifiers - ADD COLUMN managed_by_template_agent_id text, - ADD COLUMN source_template_org_id text; - -ALTER TABLE public.watchers - ADD COLUMN managed_by_template_agent_id text, - ADD COLUMN source_template_org_id text; - -CREATE INDEX idx_entity_types_managed_by_template - ON public.entity_types (managed_by_template_agent_id) - WHERE managed_by_template_agent_id IS NOT NULL; - -CREATE INDEX idx_entity_relationship_types_managed_by_template - ON public.entity_relationship_types (managed_by_template_agent_id) - WHERE managed_by_template_agent_id IS NOT NULL; - -CREATE INDEX idx_event_classifiers_managed_by_template - ON public.event_classifiers (managed_by_template_agent_id) - WHERE managed_by_template_agent_id IS NOT NULL; - -CREATE INDEX idx_watchers_managed_by_template - ON public.watchers (managed_by_template_agent_id) - WHERE managed_by_template_agent_id IS NOT NULL; - --- migrate:down - -DROP INDEX IF EXISTS public.idx_watchers_managed_by_template; -DROP INDEX IF EXISTS public.idx_event_classifiers_managed_by_template; -DROP INDEX IF EXISTS public.idx_entity_relationship_types_managed_by_template; -DROP INDEX IF EXISTS public.idx_entity_types_managed_by_template; - -ALTER TABLE public.watchers - DROP COLUMN IF EXISTS source_template_org_id, - DROP COLUMN IF EXISTS managed_by_template_agent_id; - -ALTER TABLE public.event_classifiers - DROP COLUMN IF EXISTS source_template_org_id, - DROP COLUMN IF EXISTS managed_by_template_agent_id; - -ALTER TABLE public.entity_relationship_types - DROP COLUMN IF EXISTS source_template_org_id, - DROP COLUMN IF EXISTS managed_by_template_agent_id; - -ALTER TABLE public.entity_types - DROP COLUMN IF EXISTS source_template_org_id, - DROP COLUMN IF EXISTS managed_by_template_agent_id; diff --git a/db/schema.sql b/db/schema.sql index b7ec1b525..a6331e6dd 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict bqBE913aGPDwzJsfFpJo9ktouNunyv25fIQIyfTS9VJm4SUnYw8b0K9sqZozUWV +\restrict PwjQGaJej1kRx35QDCNCfuR07o0LY8kUstkGIScyyKBLopQVzj4KOWcJsHs0Btf -- Dumped from database version 18.1 (Debian 18.1-1.pgdg13+2) -- Dumped by pg_dump version 18.1 (Homebrew) @@ -1225,8 +1225,6 @@ CREATE TABLE public.entity_relationship_types ( deleted_at timestamp with time zone, created_at timestamp with time zone DEFAULT now(), updated_at timestamp with time zone DEFAULT now(), - managed_by_template_agent_id text, - source_template_org_id text, CONSTRAINT entity_relationship_types_status_check CHECK ((status = ANY (ARRAY['active'::text, 'archived'::text]))) ); @@ -1347,9 +1345,7 @@ CREATE TABLE public.entity_types ( created_at timestamp with time zone DEFAULT now(), updated_at timestamp with time zone DEFAULT now(), current_view_template_version_id integer, - event_kinds jsonb, - managed_by_template_agent_id text, - source_template_org_id text + event_kinds jsonb ); @@ -1482,8 +1478,6 @@ CREATE TABLE public.event_classifiers ( watcher_id bigint, organization_id text, entity_ids bigint[], - managed_by_template_agent_id text, - source_template_org_id text, CONSTRAINT event_classifiers_status_check CHECK ((status = ANY (ARRAY['active'::text, 'deprecated'::text]))) ); @@ -2513,8 +2507,6 @@ CREATE TABLE public.watchers ( scheduler_client_id text, source_watcher_id integer, watcher_group_id integer NOT NULL, - managed_by_template_agent_id text, - source_template_org_id text, CONSTRAINT insights_status_check CHECK ((status = ANY (ARRAY['active'::text, 'archived'::text]))) ); @@ -3943,13 +3935,6 @@ CREATE INDEX idx_entity_rel_type_rules_type ON public.entity_relationship_type_r CREATE UNIQUE INDEX idx_entity_rel_types_org_slug ON public.entity_relationship_types USING btree (organization_id, slug) WHERE (status = 'active'::text); --- --- Name: idx_entity_relationship_types_managed_by_template; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_entity_relationship_types_managed_by_template ON public.entity_relationship_types USING btree (managed_by_template_agent_id) WHERE (managed_by_template_agent_id IS NOT NULL); - - -- -- Name: idx_entity_relationships_from; Type: INDEX; Schema: public; Owner: - -- @@ -3999,13 +3984,6 @@ CREATE INDEX idx_entity_type_audit_type_id ON public.entity_type_audit USING btr CREATE INDEX idx_entity_types_active ON public.entity_types USING btree (id) WHERE (deleted_at IS NULL); --- --- Name: idx_entity_types_managed_by_template; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_entity_types_managed_by_template ON public.entity_types USING btree (managed_by_template_agent_id) WHERE (managed_by_template_agent_id IS NOT NULL); - - -- -- Name: idx_entity_types_org_slug; Type: INDEX; Schema: public; Owner: - -- @@ -4069,13 +4047,6 @@ CREATE INDEX idx_event_classifiers_entity_ids ON public.event_classifiers USING CREATE INDEX idx_event_classifiers_insight_id ON public.event_classifiers USING btree (watcher_id) WHERE (watcher_id IS NOT NULL); --- --- Name: idx_event_classifiers_managed_by_template; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_event_classifiers_managed_by_template ON public.event_classifiers USING btree (managed_by_template_agent_id) WHERE (managed_by_template_agent_id IS NOT NULL); - - -- -- Name: idx_event_classifiers_organization_id; Type: INDEX; Schema: public; Owner: - -- @@ -4615,13 +4586,6 @@ CREATE INDEX idx_watchers_created_by ON public.watchers USING btree (created_by) CREATE INDEX idx_watchers_entity_ids ON public.watchers USING gin (entity_ids); --- --- Name: idx_watchers_managed_by_template; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_watchers_managed_by_template ON public.watchers USING btree (managed_by_template_agent_id) WHERE (managed_by_template_agent_id IS NOT NULL); - - -- -- Name: idx_watchers_next_run_at; Type: INDEX; Schema: public; Owner: - -- @@ -5890,7 +5854,7 @@ ALTER TABLE ONLY public.workspace_settings -- PostgreSQL database dump complete -- -\unrestrict bqBE913aGPDwzJsfFpJo9ktouNunyv25fIQIyfTS9VJm4SUnYw8b0K9sqZozUWV +\unrestrict PwjQGaJej1kRx35QDCNCfuR07o0LY8kUstkGIScyyKBLopQVzj4KOWcJsHs0Btf -- @@ -5924,5 +5888,4 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260424030000'), ('20260424130000'), ('20260425100000'), - ('20260425120000'), ('20260426120000'); diff --git a/packages/owletto-backend/src/__tests__/integration/agents/install-manifest-routes.test.ts b/packages/owletto-backend/src/__tests__/integration/agents/install-manifest-routes.test.ts deleted file mode 100644 index d7b7aa05f..000000000 --- a/packages/owletto-backend/src/__tests__/integration/agents/install-manifest-routes.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Hono } from 'hono'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { installManifestRoutes } from '../../../agents/install-manifest-routes'; -import type { Env } from '../../../index'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { createTestAgent, createTestOrganization } from '../../setup/test-fixtures'; - -const ORIGINAL_PHONE = process.env.PERSONAL_FINANCE_BOT_PHONE; - -describe('GET /api/install/manifest/:slug', () => { - let templateOrg: Awaited>; - let templateAgent: Awaited>; - let app: Hono<{ Bindings: Env }>; - - beforeAll(async () => { - await cleanupTestDatabase(); - - templateOrg = await createTestOrganization({ - name: 'Personal Finance', - slug: 'personal-finance', - }); - templateAgent = await createTestAgent({ - organizationId: templateOrg.id, - name: 'Personal Finance', - }); - - app = new Hono<{ Bindings: Env }>(); - app.route('/api', installManifestRoutes); - }); - - afterAll(() => { - if (ORIGINAL_PHONE === undefined) delete process.env.PERSONAL_FINANCE_BOT_PHONE; - else process.env.PERSONAL_FINANCE_BOT_PHONE = ORIGINAL_PHONE; - }); - - it('resolves slug → template agent, returns name + description + templateAgentId', async () => { - const res = await app.request('/api/install/manifest/personal-finance'); - expect(res.status).toBe(200); - const body = (await res.json()) as { - slug: string; - name: string; - templateAgentId: string; - botPhone: string | null; - }; - expect(body.slug).toBe('personal-finance'); - expect(body.name).toBe('Personal Finance'); - expect(body.templateAgentId).toBe(templateAgent.agentId); - }); - - it('returns botPhone as bare digits when env var is set', async () => { - process.env.PERSONAL_FINANCE_BOT_PHONE = '+447123456789'; - const res = await app.request('/api/install/manifest/personal-finance'); - const body = (await res.json()) as { botPhone: string | null }; - expect(body.botPhone).toBe('447123456789'); - }); - - it('returns botPhone: null when env var is unset', async () => { - delete process.env.PERSONAL_FINANCE_BOT_PHONE; - const res = await app.request('/api/install/manifest/personal-finance'); - const body = (await res.json()) as { botPhone: string | null }; - expect(body.botPhone).toBeNull(); - }); - - it('returns 404 for an unknown slug', async () => { - const res = await app.request('/api/install/manifest/no-such-template'); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('not_found'); - }); - - it('does not return a template-agent when only instance agents exist', async () => { - const sql = getTestDb(); - const orphanOrg = await createTestOrganization({ - name: 'Orphan', - slug: 'orphan-org', - }); - // An agent that points at another template — i.e. it's an INSTANCE, not a template. - const instanceId = `agent_${Math.random().toString(36).slice(2, 10).toLowerCase()}`; - await sql` - INSERT INTO agents (id, organization_id, name, owner_platform, template_agent_id, is_workspace_agent, created_at, updated_at) - VALUES (${instanceId}, ${orphanOrg.id}, 'Instance', 'owletto', ${templateAgent.agentId}, false, NOW(), NOW()) - `; - const res = await app.request('/api/install/manifest/orphan-org'); - expect(res.status).toBe(404); - }); -}); 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 deleted file mode 100644 index fd306c452..000000000 --- a/packages/owletto-backend/src/__tests__/integration/agents/install-routes.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Hono } from 'hono'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { installRoutes } from '../../../agents/install-routes'; -import { ensureMemberEntity } from '../../../utils/member-entity'; -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', - 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'); - - // Pre-create the user's $member entity, mirroring what - // provisionMemberAndCoreIdentities does during signup. Tests that exercise - // WhatsApp identity linking need this to exist. - await ensureMemberEntity({ - organizationId: personalOrg.id, - userId: user.id, - name: user.name, - email: user.email, - role: 'owner', - status: 'active', - }); - }); - - 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}`; - }); - - it('writes WhatsApp identities when whatsapp_phone is supplied', async () => { - const sql = getTestDb(); - 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, - whatsapp_phone: '07123456789', - }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - whatsapp?: { phone: string; waJid: string }; - whatsappError?: string; - }; - expect(body.whatsappError).toBeUndefined(); - expect(body.whatsapp).toEqual({ - phone: '+447123456789', - waJid: '447123456789@s.whatsapp.net', - }); - - const identities = await sql` - SELECT namespace, identifier - FROM entity_identities - WHERE organization_id = ${personalOrg.id} - AND namespace IN ('phone', 'wa_jid') - AND deleted_at IS NULL - ORDER BY namespace - `; - expect(identities.map((r: { namespace: string; identifier: string }) => r)).toEqual([ - { namespace: 'phone', identifier: '+447123456789' }, - { namespace: 'wa_jid', identifier: '447123456789@s.whatsapp.net' }, - ]); - }); - - it('reports whatsappError when the phone is unparseable but still installs', 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, - whatsapp_phone: 'not a phone', - }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - agentId: string; - whatsappError?: string; - whatsapp?: unknown; - }; - expect(body.agentId).toBeTruthy(); - expect(body.whatsapp).toBeUndefined(); - expect(body.whatsappError).toBe('invalid_phone'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/agents/install.test.ts b/packages/owletto-backend/src/__tests__/integration/agents/install.test.ts deleted file mode 100644 index 4709cebe3..000000000 --- a/packages/owletto-backend/src/__tests__/integration/agents/install.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { installAgentFromTemplate, resyncInstalledAgent } from '../../../agents/install'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAgent, - createTestOrganization, - createTestUser, -} from '../../setup/test-fixtures'; - -describe('installAgentFromTemplate', () => { - let templateOrg: Awaited>; - let templateAgent: Awaited>; - let userOrg: Awaited>; - let user: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - const sql = getTestDb(); - - templateOrg = await createTestOrganization({ - name: 'Personal Finance Template', - slug: 'personal-finance-tpl', - visibility: 'public', - }); - templateAgent = await createTestAgent({ - organizationId: templateOrg.id, - name: 'Personal Finance', - }); - - user = await createTestUser(); - userOrg = await createTestOrganization({ name: 'User Personal Org' }); - await addUserToOrganization(user.id, userOrg.id, 'owner'); - - // Seed two entity types and one relationship type in the template org. - await sql` - INSERT INTO entity_types (slug, name, description, metadata_schema, organization_id, created_by) - VALUES - ('tax_year', 'Tax Year', 'Fiscal year', '{"type":"object"}'::jsonb, ${templateOrg.id}, ${user.id}), - ('transaction', 'Transaction', 'A debit/credit', '{"type":"object"}'::jsonb, ${templateOrg.id}, ${user.id}) - `; - await sql` - INSERT INTO entity_relationship_types (slug, name, description, metadata_schema, organization_id, created_by, status) - VALUES - ('for_tax_year', 'For Tax Year', NULL, '{"type":"object"}'::jsonb, ${templateOrg.id}, ${user.id}, 'active') - `; - const watcherRows = await sql` - INSERT INTO watchers ( - organization_id, slug, name, description, status, created_by, - model_config, sources, schedule, agent_id - ) VALUES ( - ${templateOrg.id}, 'gmail-tx', 'Gmail extractor', 'Extract finance events', 'active', ${user.id}, - '{"model":"test"}'::jsonb, - '[{"name":"gmail_messages","query":"SELECT id FROM events"}]'::jsonb, - '*/30 * * * *', ${templateAgent.agentId} - ) - RETURNING id - `; - const watcherId = watcherRows[0].id as number; - const watcherVersionRows = await sql` - INSERT INTO watcher_versions ( - watcher_id, version, name, description, created_by, prompt, - extraction_schema, required_source_types, recommended_source_types, - reactions_guidance - ) VALUES ( - ${watcherId}, 1, 'Gmail extractor v1', 'Current template', ${user.id}, 'Extract {{sources.gmail_messages}}', - '{"type":"object","properties":{"transactions":{"type":"array"}}}'::jsonb, - '{google.gmail}'::text[], '{document}'::text[], 'Create transaction entities' - ) - RETURNING id - `; - await sql` - UPDATE watchers - SET current_version_id = ${watcherVersionRows[0].id as number} - WHERE id = ${watcherId} - `; - const classifierRows = await sql` - INSERT INTO event_classifiers ( - organization_id, slug, name, description, attribute_key, status, - created_by, watcher_id - ) VALUES ( - ${templateOrg.id}, 'tax-relevance', 'Tax relevance', 'Classify tax relevance', 'tax_relevance', - 'active', ${user.id}, ${watcherId} - ) - RETURNING id - `; - await sql` - INSERT INTO event_classifier_versions ( - classifier_id, version, is_current, attribute_values, min_similarity, - fallback_value, change_notes, created_by, preferred_model, extraction_config - ) VALUES ( - ${classifierRows[0].id as number}, 1, true, - '[{"value":"income","description":"Taxable income"}]'::jsonb, - 0.75, 'none', 'Initial template', ${user.id}, '@cf/meta/llama-3.1-8b-instruct', - '{"mode":"llm"}'::jsonb - ) - `; - }); - - it('creates a new agent row in the target org with template_agent_id set', async () => { - const result = await installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: userOrg.id, - userId: user.id, - }); - - expect(result.created).toBe(true); - expect(result.mirrored.entity_types).toBe(2); - expect(result.mirrored.entity_relationship_types).toBe(1); - expect(result.mirrored.watchers).toBe(1); - expect(result.mirrored.event_classifiers).toBe(1); - - const sql = getTestDb(); - const rows = await sql` - SELECT id, template_agent_id, organization_id, owner_user_id - FROM agents - WHERE id = ${result.agentId} - `; - expect(rows).toHaveLength(1); - expect(rows[0].template_agent_id).toBe(templateAgent.agentId); - expect(rows[0].organization_id).toBe(userOrg.id); - expect(rows[0].owner_user_id).toBe(user.id); - }); - - it('mirrors entity types with managed_by_template_agent_id set', async () => { - const sql = getTestDb(); - const rows = await sql` - SELECT slug, managed_by_template_agent_id, source_template_org_id - FROM entity_types - WHERE organization_id = ${userOrg.id} - ORDER BY slug - `; - expect(rows.map((r: { slug: string }) => r.slug)).toEqual(['tax_year', 'transaction']); - for (const row of rows) { - expect(row.managed_by_template_agent_id).toBe(templateAgent.agentId); - expect(row.source_template_org_id).toBe(templateOrg.id); - } - }); - - it('mirrors relationship types with managed_by_template_agent_id set', async () => { - const sql = getTestDb(); - const rows = await sql` - SELECT slug, managed_by_template_agent_id, source_template_org_id - FROM entity_relationship_types - WHERE organization_id = ${userOrg.id} - ORDER BY slug - `; - expect(rows.map((r: { slug: string }) => r.slug)).toEqual(['for_tax_year']); - expect(rows[0].managed_by_template_agent_id).toBe(templateAgent.agentId); - }); - - it('mirrors watcher definitions with the installed agent as owner', async () => { - const sql = getTestDb(); - const rows = await sql` - SELECT - w.slug, - w.agent_id, - w.connection_id, - w.entity_ids, - w.managed_by_template_agent_id, - w.source_template_org_id, - v.prompt, - v.reactions_guidance - FROM watchers w - JOIN watcher_versions v ON v.id = w.current_version_id - WHERE w.organization_id = ${userOrg.id} - AND w.slug = 'gmail-tx' - LIMIT 1 - `; - expect(rows).toHaveLength(1); - expect(rows[0].managed_by_template_agent_id).toBe(templateAgent.agentId); - expect(rows[0].source_template_org_id).toBe(templateOrg.id); - expect(rows[0].connection_id).toBeNull(); - expect(rows[0].entity_ids).toBeNull(); - expect(rows[0].agent_id).toBeTruthy(); - expect(rows[0].prompt).toContain('{{sources.gmail_messages}}'); - expect(rows[0].reactions_guidance).toContain('transaction'); - }); - - it('mirrors watcher-scoped classifiers and their current version', async () => { - const sql = getTestDb(); - const rows = await sql` - SELECT - c.slug, - c.watcher_id, - c.managed_by_template_agent_id, - v.version, - v.is_current, - v.fallback_value, - v.extraction_config - FROM event_classifiers c - JOIN event_classifier_versions v ON v.classifier_id = c.id - WHERE c.organization_id = ${userOrg.id} - AND c.slug = 'tax-relevance' - LIMIT 1 - `; - expect(rows).toHaveLength(1); - expect(rows[0].managed_by_template_agent_id).toBe(templateAgent.agentId); - expect(rows[0].watcher_id).toBeTruthy(); - expect(rows[0].version).toBe(1); - expect(rows[0].is_current).toBe(true); - expect(rows[0].fallback_value).toBe('none'); - expect(rows[0].extraction_config).toEqual({ mode: 'llm' }); - }); - - it('is idempotent: re-installing updates rather than creating duplicates', async () => { - const result = await installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: userOrg.id, - userId: user.id, - }); - expect(result.created).toBe(false); - - const sql = getTestDb(); - const agentCount = await sql` - SELECT COUNT(*)::int AS count - FROM agents - WHERE template_agent_id = ${templateAgent.agentId} - AND organization_id = ${userOrg.id} - `; - expect(agentCount[0].count).toBe(1); - - const typeCount = await sql` - SELECT COUNT(*)::int AS count - FROM entity_types - WHERE organization_id = ${userOrg.id} - AND managed_by_template_agent_id = ${templateAgent.agentId} - `; - expect(typeCount[0].count).toBe(2); - - const watcherCount = await sql` - SELECT COUNT(*)::int AS count - FROM watchers - WHERE organization_id = ${userOrg.id} - AND managed_by_template_agent_id = ${templateAgent.agentId} - `; - expect(watcherCount[0].count).toBe(1); - - const classifierCount = await sql` - SELECT COUNT(*)::int AS count - FROM event_classifiers - WHERE organization_id = ${userOrg.id} - AND managed_by_template_agent_id = ${templateAgent.agentId} - `; - expect(classifierCount[0].count).toBe(1); - }); - - it('re-sync propagates template changes to the mirror', async () => { - const sql = getTestDb(); - // Simulate a template-side description change. - await sql` - UPDATE entity_types - SET description = 'UK fiscal year (6 April to 5 April)' - WHERE organization_id = ${templateOrg.id} - AND slug = 'tax_year' - `; - - const installed = await sql` - SELECT id FROM agents - WHERE template_agent_id = ${templateAgent.agentId} - AND organization_id = ${userOrg.id} - LIMIT 1 - `; - await resyncInstalledAgent({ - installedAgentId: installed[0].id as string, - userId: user.id, - }); - - const mirrored = await sql` - SELECT description FROM entity_types - WHERE organization_id = ${userOrg.id} - AND slug = 'tax_year' - `; - expect(mirrored[0].description).toBe('UK fiscal year (6 April to 5 April)'); - }); - - it('refuses to install a template from a private org', async () => { - const privateTemplateOrg = await createTestOrganization({ name: 'Private Template' }); - const privateTemplateAgent = await createTestAgent({ - organizationId: privateTemplateOrg.id, - name: 'Private Template Agent', - }); - - await expect( - installAgentFromTemplate({ - templateAgentId: privateTemplateAgent.agentId, - targetOrganizationId: userOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/organization is not public/); - }); - - it('refuses to install an already-installed agent as a source template', async () => { - const sql = getTestDb(); - const installed = await sql` - SELECT id FROM agents - WHERE template_agent_id = ${templateAgent.agentId} - AND organization_id = ${userOrg.id} - LIMIT 1 - `; - const otherOrg = await createTestOrganization({ name: 'Other Install Target' }); - - await expect( - installAgentFromTemplate({ - templateAgentId: installed[0].id as string, - targetOrganizationId: otherOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/cannot be used as a source template/); - }); - - it('refuses to install into the template org itself', async () => { - await expect( - installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: templateOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/Cannot install template agent into its own org/); - }); - - it('refuses to install when the user is not a member of the target org', async () => { - const strangerOrg = await createTestOrganization({ name: 'Stranger Org' }); - await expect( - installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: strangerOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/not a member/); - }); - - it('refuses to install when the user is a member but lacks admin/owner role', async () => { - const memberOrg = await createTestOrganization({ name: 'Member-Only Org' }); - await addUserToOrganization(user.id, memberOrg.id, 'member'); - await expect( - installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: memberOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/owner or admin role/); - }); - - it('refuses to overwrite a user-authored row of the same slug', async () => { - const sql = getTestDb(); - const otherOrg = await createTestOrganization({ name: 'Other User Org' }); - await addUserToOrganization(user.id, otherOrg.id, 'owner'); - await sql` - INSERT INTO entity_types (slug, name, description, metadata_schema, organization_id, created_by) - VALUES ('transaction', 'User Transaction', 'Manual row', '{"type":"object"}'::jsonb, ${otherOrg.id}, ${user.id}) - `; - await expect( - installAgentFromTemplate({ - templateAgentId: templateAgent.agentId, - targetOrganizationId: otherOrg.id, - userId: user.id, - }) - ).rejects.toThrow(/user-authored/); - }); -}); diff --git a/packages/owletto-backend/src/agents/install-manifest-routes.ts b/packages/owletto-backend/src/agents/install-manifest-routes.ts deleted file mode 100644 index 217591022..000000000 --- a/packages/owletto-backend/src/agents/install-manifest-routes.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Public install-discovery endpoint. - * - * GET /api/install/manifest/:slug - * → { slug, name, description, botPhone, templateAgentId } - * - * Lets a public landing page render itself + build a `wa.me/` - * link without baking the agent ID or bot phone into a Vite build. The - * frontend only ever knows the URL slug; everything else is server-resolved. - * - * Resolution rules: - * - The URL slug equals the template org's slug. We currently maintain - * one canonical template agent per template org (`agents.template_agent_id - * IS NULL` row in that org). - * - The bot's E.164 phone number (without `+`) is operator config. v1 - * reads it from a per-slug env var (e.g. - * PERSONAL_FINANCE_BOT_PHONE=447123456789). When unset the manifest - * reports `botPhone: null` and the landing page falls back to a - * "message the bot to start" instruction. - * - * No auth — manifests are public marketing data. - */ - -import { Hono } from 'hono'; -import { getDb } from '../db/client'; -import type { Env } from '../index'; - -const installManifestRoutes = new Hono<{ Bindings: Env }>(); - -interface ManifestRow { - id: string; - name: string; - description: string | null; -} - -async function loadCanonicalTemplate(slug: string): Promise { - const sql = getDb(); - const rows = await sql` - SELECT a.id, a.name, a.description - FROM agents a - JOIN "organization" o ON o.id = a.organization_id - WHERE o.slug = ${slug} - AND a.template_agent_id IS NULL - ORDER BY a.created_at ASC - LIMIT 1 - `; - if (rows.length === 0) return null; - return rows[0] as ManifestRow; -} - -function botPhoneForSlug(slug: string): string | null { - // Map URL slug → server env var. Adding a new productized template means - // adding one row here + setting the env. Avoids any frontend rebuild. - const envByName: Record = { - 'personal-finance': process.env.PERSONAL_FINANCE_BOT_PHONE, - }; - const raw = envByName[slug]; - if (!raw) return null; - // Accept "+447..." or "447..." in the env; surface to the wire as bare digits - // so the landing page can plug it straight into wa.me/. - return raw.replace(/^\+/, '').replace(/[^0-9]/g, '') || null; -} - -installManifestRoutes.get('/install/manifest/:slug', async (c) => { - const slug = c.req.param('slug'); - if (!slug) return c.json({ error: 'slug required' }, 400); - - const template = await loadCanonicalTemplate(slug); - if (!template) { - return c.json({ error: 'not_found', message: `No installable template at /${slug}` }, 404); - } - - return c.json({ - slug, - name: template.name, - description: template.description, - botPhone: botPhoneForSlug(slug), - templateAgentId: template.id, - }); -}); - -export { installManifestRoutes }; diff --git a/packages/owletto-backend/src/agents/install-routes.ts b/packages/owletto-backend/src/agents/install-routes.ts deleted file mode 100644 index 7346b2bac..000000000 --- a/packages/owletto-backend/src/agents/install-routes.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Public install endpoints backing the /install/:slug landing pages. - * - * A signed-in user POSTs { slug, whatsapp_phone? } (or the equivalent - * { templateAgentId } form) and we: - * 1. Look up their personal org (created by the user.create.after hook). - * 2. Resolve the slug to its canonical template agent (or accept an - * explicit templateAgentId from internal callers). - * 3. Mirror the template's schema into that org via installAgentFromTemplate. - * 4. Optionally write a WhatsApp identity (`wa_jid` + `phone`) on their - * $member entity so the gateway can later route inbound WhatsApp - * messages from that number back to this user's org. - * - * Returns the new agent id, the target org slug, and a redirectTo path. - */ - -import { type Context, Hono } from 'hono'; -import { requireAuth } from '../auth/middleware'; -import { linkWhatsAppToMember } from '../auth/subject-identities'; -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 }; -} - -async function resolveTemplateAgentBySlug(slug: string): Promise { - const sql = getDb(); - const rows = await sql` - SELECT a.id - FROM agents a - JOIN "organization" o ON o.id = a.organization_id - WHERE o.slug = ${slug} - AND a.template_agent_id IS NULL - ORDER BY a.created_at ASC - LIMIT 1 - `; - if (rows.length === 0) return null; - return rows[0].id 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: { - slug?: string; - templateAgentId?: string; - name?: string; - whatsapp_phone?: string; - }; - try { - body = await c.req.json(); - } catch { - return c.json({ error: 'Invalid JSON body' }, 400); - } - - let templateAgentId: string; - if (typeof body.slug === 'string' && body.slug.trim()) { - const resolved = await resolveTemplateAgentBySlug(body.slug.trim()); - if (!resolved) { - return c.json( - { error: 'unknown_slug', message: `No installable template at /${body.slug}` }, - 404 - ); - } - templateAgentId = resolved; - } else if (typeof body.templateAgentId === 'string' && body.templateAgentId.trim()) { - templateAgentId = body.templateAgentId.trim(); - } else { - return c.json({ error: '`slug` or `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 - ); - } - - let installResult: Awaited>; - try { - installResult = await installAgentFromTemplate({ - templateAgentId, - targetOrganizationId: personalOrg.id, - userId: user.id, - name: body.name, - }); - } catch (error) { - return c.json({ error: errorMessage(error) }, 400); - } - - // Optional: link the user's WhatsApp number to their $member so inbound - // WA messages can be routed back here. Failure is non-fatal — agent is - // already installed; user can re-link later. - let whatsapp: { phone: string; waJid: string } | undefined; - let whatsappError: 'invalid_phone' | 'no_member' | undefined; - if (body.whatsapp_phone && typeof body.whatsapp_phone === 'string') { - const result = await linkWhatsAppToMember({ - organizationId: personalOrg.id, - email: user.email, - rawPhone: body.whatsapp_phone, - }); - if ('error' in result) { - whatsappError = result.error; - } else { - whatsapp = result; - } - } - - return c.json({ - agentId: installResult.agentId, - organizationId: installResult.organizationId, - organizationSlug: personalOrg.slug, - created: installResult.created, - mirrored: installResult.mirrored, - redirectTo: `/${personalOrg.slug}/agents/${installResult.agentId}`, - ...(whatsapp ? { whatsapp } : {}), - ...(whatsappError ? { whatsappError } : {}), - }); -}); - -export { installRoutes }; diff --git a/packages/owletto-backend/src/agents/install.ts b/packages/owletto-backend/src/agents/install.ts deleted file mode 100644 index 7630127b0..000000000 --- a/packages/owletto-backend/src/agents/install.ts +++ /dev/null @@ -1,900 +0,0 @@ -/** - * Install a template agent into a target organization. - * - * A template agent (e.g. the `personal-finance` agent in examples/) lives in a - * template org and owns a canonical set of entity types and relationship types - * that define its data model. When a user installs the agent we: - * - * 1. Create a new agents row in the user's org with template_agent_id set - * (Lobu's existing template-inheritance applies to agents.* settings — - * prompt, tools, mcp_servers, etc. — without any copy step). - * 2. Mirror the template org's entity_types, entity_relationship_types, - * classifiers and watcher definitions into the user's org, tagged - * managed_by_template_agent_id so we can re-sync on template updates and - * treat them as read-only from the user's side. - * - * Watcher/classifier mirrors copy definitions only — not historical windows, - * reactions or classifications. - * The install is idempotent: re-running against the same target simply - * UPDATEs the mirror rows (allowing template schema evolution). - * - * Safety: the mirror never overwrites rows the user authored directly - * (managed_by_template_agent_id IS NULL). Slug collisions of that kind - * abort the install with a descriptive error. - */ - -import { generateSecureToken } from '../auth/oauth/utils'; -import { type DbClient, getDb, pgTextArray } from '../db/client'; - -export interface InstallResult { - agentId: string; - organizationId: string; - mirrored: { - entity_types: number; - entity_relationship_types: number; - event_classifiers: number; - watchers: number; - }; - created: boolean; -} - -export interface InstallAgentParams { - templateAgentId: string; - targetOrganizationId: string; - userId: string; - /** Optional override for the installed agent's display name. */ - name?: string; -} - -type Sql = DbClient; - -interface TemplateAgentRow { - id: string; - organization_id: string; - name: string; - description: string | null; - template_agent_id: string | null; - organization_visibility: string; -} - -async function loadTemplateAgent(sql: Sql, templateAgentId: string): Promise { - const rows = await sql` - SELECT - a.id, - a.organization_id, - a.name, - a.description, - a.template_agent_id, - o.visibility AS organization_visibility - FROM agents a - JOIN "organization" o ON o.id = a.organization_id - WHERE a.id = ${templateAgentId} - LIMIT 1 - `; - if (rows.length === 0) { - throw new Error(`Template agent ${templateAgentId} not found`); - } - return rows[0] as TemplateAgentRow; -} - -async function findExistingInstall( - sql: Sql, - templateAgentId: string, - targetOrganizationId: string -): Promise { - const rows = await sql` - SELECT id FROM agents - WHERE template_agent_id = ${templateAgentId} - AND organization_id = ${targetOrganizationId} - LIMIT 1 - `; - return rows.length > 0 ? (rows[0].id as string) : null; -} - -async function upsertInstalledAgent( - sql: Sql, - params: { - existingAgentId: string | null; - template: TemplateAgentRow; - targetOrganizationId: string; - userId: string; - name?: string; - } -): Promise<{ agentId: string; created: boolean }> { - if (params.existingAgentId) { - await sql` - UPDATE agents - SET updated_at = NOW(), - name = ${params.name ?? params.template.name}, - description = ${params.template.description} - WHERE id = ${params.existingAgentId} - `; - return { agentId: params.existingAgentId, created: false }; - } - - const agentId = `agent_${generateSecureToken(8).toLowerCase()}`; - await sql` - INSERT INTO agents ( - id, organization_id, name, description, - owner_platform, owner_user_id, - template_agent_id, - is_workspace_agent, - created_at, updated_at - ) VALUES ( - ${agentId}, - ${params.targetOrganizationId}, - ${params.name ?? params.template.name}, - ${params.template.description}, - 'owletto', - ${params.userId}, - ${params.template.id}, - false, - NOW(), NOW() - ) - `; - return { agentId, created: true }; -} - -interface EntityTypeRow { - slug: string; - name: string; - description: string | null; - icon: string | null; - color: string | null; - metadata_schema: Record | null; - event_kinds: Record | null; -} - -async function mirrorEntityTypes( - sql: Sql, - templateOrgId: string, - targetOrgId: string, - templateAgentId: string, - userId: string -): Promise { - const templateRows = (await sql` - SELECT slug, name, description, icon, color, metadata_schema, event_kinds - FROM entity_types - WHERE organization_id = ${templateOrgId} - AND deleted_at IS NULL - `) as EntityTypeRow[]; - - let count = 0; - for (const row of templateRows) { - const existing = await sql` - SELECT id, managed_by_template_agent_id - FROM entity_types - WHERE organization_id = ${targetOrgId} - AND slug = ${row.slug} - AND deleted_at IS NULL - LIMIT 1 - `; - - const metadataSchema = row.metadata_schema ? sql.json(row.metadata_schema) : null; - const eventKinds = row.event_kinds ? sql.json(row.event_kinds) : null; - - if (existing.length === 0) { - await sql` - INSERT INTO entity_types ( - slug, name, description, icon, color, - metadata_schema, event_kinds, - organization_id, created_by, - managed_by_template_agent_id, source_template_org_id, - created_at, updated_at - ) VALUES ( - ${row.slug}, ${row.name}, ${row.description}, - ${row.icon}, ${row.color}, - ${metadataSchema}, ${eventKinds}, - ${targetOrgId}, ${userId}, - ${templateAgentId}, ${templateOrgId}, - NOW(), NOW() - ) - `; - count++; - continue; - } - - const existingOwner = existing[0].managed_by_template_agent_id as string | null; - if (existingOwner === null) { - throw new Error( - `Entity type '${row.slug}' already exists in the target org as a user-authored row. Remove it or rename before installing this agent.` - ); - } - if (existingOwner !== templateAgentId) { - throw new Error( - `Entity type '${row.slug}' is already managed by a different template agent (${existingOwner}).` - ); - } - - await sql` - UPDATE entity_types - SET name = ${row.name}, - description = ${row.description}, - icon = ${row.icon}, - color = ${row.color}, - metadata_schema = ${metadataSchema}, - event_kinds = ${eventKinds}, - updated_at = NOW(), - updated_by = ${userId} - WHERE id = ${existing[0].id} - `; - count++; - } - return count; -} - -interface RelationshipTypeRow { - slug: string; - name: string; - description: string | null; - metadata_schema: Record | null; - is_symmetric: boolean; -} - -async function mirrorRelationshipTypes( - sql: Sql, - templateOrgId: string, - targetOrgId: string, - templateAgentId: string, - userId: string -): Promise { - const templateRows = (await sql` - SELECT slug, name, description, metadata_schema, is_symmetric - FROM entity_relationship_types - WHERE organization_id = ${templateOrgId} - AND status = 'active' - `) as RelationshipTypeRow[]; - - let count = 0; - for (const row of templateRows) { - const existing = await sql` - SELECT id, managed_by_template_agent_id - FROM entity_relationship_types - WHERE organization_id = ${targetOrgId} - AND slug = ${row.slug} - AND status = 'active' - LIMIT 1 - `; - - const metadataSchema = row.metadata_schema ? sql.json(row.metadata_schema) : null; - - if (existing.length === 0) { - await sql` - INSERT INTO entity_relationship_types ( - slug, name, description, metadata_schema, - is_symmetric, organization_id, created_by, - managed_by_template_agent_id, source_template_org_id, - status, created_at, updated_at - ) VALUES ( - ${row.slug}, ${row.name}, ${row.description}, ${metadataSchema}, - ${row.is_symmetric}, ${targetOrgId}, ${userId}, - ${templateAgentId}, ${templateOrgId}, - 'active', NOW(), NOW() - ) - `; - count++; - continue; - } - - const existingOwner = existing[0].managed_by_template_agent_id as string | null; - if (existingOwner === null) { - throw new Error( - `Relationship type '${row.slug}' already exists in the target org as a user-authored row. Remove it or rename before installing this agent.` - ); - } - if (existingOwner !== templateAgentId) { - throw new Error( - `Relationship type '${row.slug}' is already managed by a different template agent (${existingOwner}).` - ); - } - - await sql` - UPDATE entity_relationship_types - SET name = ${row.name}, - description = ${row.description}, - metadata_schema = ${metadataSchema}, - is_symmetric = ${row.is_symmetric}, - updated_at = NOW() - WHERE id = ${existing[0].id} - `; - count++; - } - return count; -} - -interface TemplateWatcherRow { - id: number; - model_config: Record | null; - sources: unknown[] | null; - reaction_script: string | null; - reaction_script_compiled: string | null; - name: string | null; - slug: string | null; - description: string | null; - version: number | null; - tags: string[] | null; - registry_type: string | null; - registry_repo: string | null; - registry_ref: string | null; - current_version_id: number | null; - schedule: string | null; -} - -interface TemplateWatcherVersionRow { - id: number; - version: number; - name: string; - description: string | null; - change_notes: string | null; - sources_schema: Record | null; - keying_config: Record | null; - json_template: Record | null; - prompt: string; - extraction_schema: Record; - classifiers: Record | null; - required_source_types: string[] | null; - recommended_source_types: string[] | null; - source_repository: string | null; - source_ref: string | null; - source_commit_sha: string | null; - source_path: string | null; - reactions_guidance: string | null; - condensation_prompt: string | null; - condensation_window_count: number | null; - version_sources: Record | null; -} - -function jsonOrNull(sql: Sql, value: unknown): unknown { - return value == null ? null : sql.json(value); -} - -async function loadCurrentWatcherVersion( - sql: Sql, - versionId: number | null -): Promise { - if (versionId === null) return null; - const rows = (await sql` - SELECT - id, - version, - name, - description, - change_notes, - sources_schema, - keying_config, - json_template, - prompt, - extraction_schema, - classifiers, - required_source_types, - recommended_source_types, - source_repository, - source_ref, - source_commit_sha, - source_path, - reactions_guidance, - condensation_prompt, - condensation_window_count, - version_sources - FROM watcher_versions - WHERE id = ${versionId} - LIMIT 1 - `) as TemplateWatcherVersionRow[]; - return rows[0] ?? null; -} - -async function upsertWatcherVersion( - sql: Sql, - row: TemplateWatcherVersionRow, - targetWatcherId: number, - userId: string, - existingVersionId: number | null -): Promise { - const sourcesSchema = jsonOrNull(sql, row.sources_schema); - const keyingConfig = jsonOrNull(sql, row.keying_config); - const jsonTemplate = jsonOrNull(sql, row.json_template); - const extractionSchema = sql.json(row.extraction_schema); - const classifiers = jsonOrNull(sql, row.classifiers); - const versionSources = jsonOrNull(sql, row.version_sources); - const requiredSourceTypes = pgTextArray(row.required_source_types ?? []); - const recommendedSourceTypes = pgTextArray(row.recommended_source_types ?? []); - - if (existingVersionId !== null) { - await sql` - UPDATE watcher_versions - SET version = ${row.version}, - name = ${row.name}, - description = ${row.description}, - change_notes = ${row.change_notes}, - sources_schema = ${sourcesSchema}, - keying_config = ${keyingConfig}, - json_template = ${jsonTemplate}, - prompt = ${row.prompt}, - extraction_schema = ${extractionSchema}, - classifiers = ${classifiers}, - required_source_types = ${requiredSourceTypes}::text[], - recommended_source_types = ${recommendedSourceTypes}::text[], - source_repository = ${row.source_repository}, - source_ref = ${row.source_ref}, - source_commit_sha = ${row.source_commit_sha}, - source_path = ${row.source_path}, - reactions_guidance = ${row.reactions_guidance}, - condensation_prompt = ${row.condensation_prompt}, - condensation_window_count = ${row.condensation_window_count ?? 4}, - version_sources = ${versionSources} - WHERE id = ${existingVersionId} - `; - return existingVersionId; - } - - const inserted = await sql` - INSERT INTO watcher_versions ( - version, name, description, change_notes, created_by, - sources_schema, keying_config, json_template, prompt, extraction_schema, - classifiers, required_source_types, recommended_source_types, - source_repository, source_ref, source_commit_sha, source_path, - reactions_guidance, condensation_prompt, condensation_window_count, - watcher_id, version_sources - ) VALUES ( - ${row.version}, ${row.name}, ${row.description}, ${row.change_notes}, ${userId}, - ${sourcesSchema}, ${keyingConfig}, ${jsonTemplate}, ${row.prompt}, ${extractionSchema}, - ${classifiers}, ${requiredSourceTypes}::text[], ${recommendedSourceTypes}::text[], - ${row.source_repository}, ${row.source_ref}, ${row.source_commit_sha}, ${row.source_path}, - ${row.reactions_guidance}, ${row.condensation_prompt}, ${row.condensation_window_count ?? 4}, - ${targetWatcherId}, ${versionSources} - ) - RETURNING id - `; - return inserted[0].id as number; -} - -async function mirrorWatchers( - sql: Sql, - templateOrgId: string, - targetOrgId: string, - templateAgentId: string, - installedAgentId: string, - userId: string -): Promise<{ count: number; watcherIdsByTemplateId: Map }> { - const templateRows = (await sql` - SELECT - id, - model_config, - sources, - reaction_script, - reaction_script_compiled, - name, - slug, - description, - version, - tags, - registry_type, - registry_repo, - registry_ref, - current_version_id, - schedule - FROM watchers - WHERE organization_id = ${templateOrgId} - AND status = 'active' - `) as TemplateWatcherRow[]; - - let count = 0; - const watcherIdsByTemplateId = new Map(); - - for (const row of templateRows) { - if (!row.slug) { - throw new Error(`Template watcher ${row.id} has no slug — cannot mirror it safely.`); - } - - const existing = await sql` - SELECT id, managed_by_template_agent_id, current_version_id - FROM watchers - WHERE organization_id = ${targetOrgId} - AND slug = ${row.slug} - AND status = 'active' - LIMIT 1 - `; - - const modelConfig = sql.json(row.model_config ?? {}); - const sources = sql.json(row.sources ?? []); - const tags = pgTextArray(row.tags ?? []); - let targetWatcherId: number; - let existingVersionId: number | null = null; - - if (existing.length === 0) { - const inserted = await sql` - INSERT INTO watchers ( - model_config, status, sources, created_by, entity_ids, - reaction_script, reaction_script_compiled, organization_id, - name, slug, description, version, tags, - registry_type, registry_repo, registry_ref, - schedule, next_run_at, agent_id, connection_id, scheduler_client_id, - managed_by_template_agent_id, source_template_org_id, - created_at, updated_at - ) VALUES ( - ${modelConfig}, 'active', ${sources}, ${userId}, NULL, - ${row.reaction_script}, ${row.reaction_script_compiled}, ${targetOrgId}, - ${row.name}, ${row.slug}, ${row.description}, ${row.version ?? 1}, ${tags}::text[], - ${row.registry_type}, ${row.registry_repo}, ${row.registry_ref}, - ${row.schedule}, NULL, ${installedAgentId}, NULL, NULL, - ${templateAgentId}, ${templateOrgId}, - NOW(), NOW() - ) - RETURNING id - `; - targetWatcherId = inserted[0].id as number; - } else { - const existingOwner = existing[0].managed_by_template_agent_id as string | null; - if (existingOwner === null) { - throw new Error( - `Watcher '${row.slug}' already exists in the target org as a user-authored row. Remove it or rename before installing this agent.` - ); - } - if (existingOwner !== templateAgentId) { - throw new Error( - `Watcher '${row.slug}' is already managed by a different template agent (${existingOwner}).` - ); - } - targetWatcherId = existing[0].id as number; - existingVersionId = (existing[0].current_version_id as number | null) ?? null; - await sql` - UPDATE watchers - SET model_config = ${modelConfig}, - sources = ${sources}, - reaction_script = ${row.reaction_script}, - reaction_script_compiled = ${row.reaction_script_compiled}, - name = ${row.name}, - description = ${row.description}, - version = ${row.version ?? 1}, - tags = ${tags}::text[], - registry_type = ${row.registry_type}, - registry_repo = ${row.registry_repo}, - registry_ref = ${row.registry_ref}, - schedule = ${row.schedule}, - next_run_at = NULL, - agent_id = ${installedAgentId}, - connection_id = NULL, - scheduler_client_id = NULL, - updated_at = NOW() - WHERE id = ${targetWatcherId} - `; - } - - const version = await loadCurrentWatcherVersion(sql, row.current_version_id); - if (version) { - const targetVersionId = await upsertWatcherVersion( - sql, - version, - targetWatcherId, - userId, - existingVersionId - ); - await sql` - UPDATE watchers - SET current_version_id = ${targetVersionId}, updated_at = NOW() - WHERE id = ${targetWatcherId} - `; - } - - watcherIdsByTemplateId.set(row.id, targetWatcherId); - count++; - } - - return { count, watcherIdsByTemplateId }; -} - -interface TemplateClassifierRow { - id: number; - slug: string; - name: string; - description: string | null; - attribute_key: string; - watcher_id: number | null; -} - -interface TemplateClassifierVersionRow { - version: number; - is_current: boolean; - attribute_values: Record; - min_similarity: string | number | null; - fallback_value: string | null; - change_notes: string | null; - preferred_model: string | null; - extraction_config: Record | null; -} - -async function loadCurrentClassifierVersion( - sql: Sql, - classifierId: number -): Promise { - const rows = (await sql` - SELECT - version, - is_current, - attribute_values, - min_similarity, - fallback_value, - change_notes, - preferred_model, - extraction_config - FROM event_classifier_versions - WHERE classifier_id = ${classifierId} - AND is_current = true - ORDER BY version DESC - LIMIT 1 - `) as TemplateClassifierVersionRow[]; - return rows[0] ?? null; -} - -async function upsertClassifierVersion( - sql: Sql, - row: TemplateClassifierVersionRow, - targetClassifierId: number, - userId: string -): Promise { - const attributeValues = sql.json(row.attribute_values); - const extractionConfig = jsonOrNull(sql, row.extraction_config); - - await sql` - UPDATE event_classifier_versions - SET is_current = false - WHERE classifier_id = ${targetClassifierId} - `; - - const existing = await sql` - SELECT id FROM event_classifier_versions - WHERE classifier_id = ${targetClassifierId} - AND version = ${row.version} - LIMIT 1 - `; - - if (existing.length > 0) { - await sql` - UPDATE event_classifier_versions - SET is_current = true, - attribute_values = ${attributeValues}, - min_similarity = ${row.min_similarity}, - fallback_value = ${row.fallback_value}, - change_notes = ${row.change_notes}, - preferred_model = ${row.preferred_model}, - extraction_config = ${extractionConfig} - WHERE id = ${existing[0].id} - `; - return; - } - - await sql` - INSERT INTO event_classifier_versions ( - classifier_id, version, is_current, attribute_values, min_similarity, - fallback_value, change_notes, created_by, preferred_model, extraction_config - ) VALUES ( - ${targetClassifierId}, ${row.version}, true, ${attributeValues}, ${row.min_similarity}, - ${row.fallback_value}, ${row.change_notes}, ${userId}, ${row.preferred_model}, ${extractionConfig} - ) - `; -} - -async function mirrorEventClassifiers( - sql: Sql, - templateOrgId: string, - targetOrgId: string, - templateAgentId: string, - userId: string, - watcherIdsByTemplateId: Map -): Promise { - const templateRows = (await sql` - SELECT id, slug, name, description, attribute_key, watcher_id - FROM event_classifiers - WHERE organization_id = ${templateOrgId} - AND status = 'active' - `) as TemplateClassifierRow[]; - - let count = 0; - for (const row of templateRows) { - const targetWatcherId = row.watcher_id ? watcherIdsByTemplateId.get(row.watcher_id) : null; - if (row.watcher_id && !targetWatcherId) { - continue; - } - - const existing = await sql` - SELECT id, managed_by_template_agent_id - FROM event_classifiers - WHERE organization_id = ${targetOrgId} - AND slug = ${row.slug} - AND status = 'active' - AND ( - (${targetWatcherId ?? null}::int IS NULL AND watcher_id IS NULL) - OR watcher_id = ${targetWatcherId ?? null} - ) - LIMIT 1 - `; - - let targetClassifierId: number; - if (existing.length === 0) { - const inserted = await sql` - INSERT INTO event_classifiers ( - slug, name, description, attribute_key, status, - created_by, entity_id, watcher_id, organization_id, entity_ids, - managed_by_template_agent_id, source_template_org_id, - created_at, updated_at - ) VALUES ( - ${row.slug}, ${row.name}, ${row.description}, ${row.attribute_key}, 'active', - ${userId}, NULL, ${targetWatcherId ?? null}, ${targetOrgId}, NULL, - ${templateAgentId}, ${templateOrgId}, - NOW(), NOW() - ) - RETURNING id - `; - targetClassifierId = inserted[0].id as number; - } else { - const existingOwner = existing[0].managed_by_template_agent_id as string | null; - if (existingOwner === null) { - throw new Error( - `Classifier '${row.slug}' already exists in the target org as a user-authored row. Remove it or rename before installing this agent.` - ); - } - if (existingOwner !== templateAgentId) { - throw new Error( - `Classifier '${row.slug}' is already managed by a different template agent (${existingOwner}).` - ); - } - targetClassifierId = existing[0].id as number; - await sql` - UPDATE event_classifiers - SET name = ${row.name}, - description = ${row.description}, - attribute_key = ${row.attribute_key}, - watcher_id = ${targetWatcherId ?? null}, - entity_id = NULL, - entity_ids = NULL, - updated_at = NOW() - WHERE id = ${targetClassifierId} - `; - } - - const version = await loadCurrentClassifierVersion(sql, row.id); - if (version) { - await upsertClassifierVersion(sql, version, targetClassifierId, userId); - } - count++; - } - return count; -} - -export async function installAgentFromTemplate( - params: InstallAgentParams -): Promise { - const sql = getDb(); - const template = await loadTemplateAgent(sql, params.templateAgentId); - - if (template.template_agent_id) { - throw new Error( - `Agent ${params.templateAgentId} is itself installed from a template and cannot be used as a source template.` - ); - } - - if (template.organization_visibility !== 'public') { - throw new Error( - `Template agent ${params.templateAgentId} is not installable because its organization is not public.` - ); - } - - if (template.organization_id === params.targetOrganizationId) { - throw new Error( - `Cannot install template agent into its own org (${template.organization_id}). Pick a different target.` - ); - } - - const membership = await sql<{ role: string }[]>` - SELECT role FROM "member" - WHERE "organizationId" = ${params.targetOrganizationId} - AND "userId" = ${params.userId} - LIMIT 1 - `; - if (membership.length === 0) { - throw new Error( - `User ${params.userId} is not a member of organization ${params.targetOrganizationId}.` - ); - } - if (membership[0].role !== 'owner' && membership[0].role !== 'admin') { - throw new Error( - `Installing a template agent requires owner or admin role in organization ${params.targetOrganizationId}.` - ); - } - - let result: InstallResult | null = null; - - await sql.begin(async (tx) => { - const existingAgentId = await findExistingInstall( - tx, - params.templateAgentId, - params.targetOrganizationId - ); - const upsert = await upsertInstalledAgent(tx, { - existingAgentId, - template, - targetOrganizationId: params.targetOrganizationId, - userId: params.userId, - name: params.name, - }); - - const entityTypes = await mirrorEntityTypes( - tx, - template.organization_id, - params.targetOrganizationId, - params.templateAgentId, - params.userId - ); - const relationshipTypes = await mirrorRelationshipTypes( - tx, - template.organization_id, - params.targetOrganizationId, - params.templateAgentId, - params.userId - ); - const watcherMirror = await mirrorWatchers( - tx, - template.organization_id, - params.targetOrganizationId, - params.templateAgentId, - upsert.agentId, - params.userId - ); - const classifiers = await mirrorEventClassifiers( - tx, - template.organization_id, - params.targetOrganizationId, - params.templateAgentId, - params.userId, - watcherMirror.watcherIdsByTemplateId - ); - - result = { - agentId: upsert.agentId, - organizationId: params.targetOrganizationId, - mirrored: { - entity_types: entityTypes, - entity_relationship_types: relationshipTypes, - event_classifiers: classifiers, - watchers: watcherMirror.count, - }, - created: upsert.created, - }; - }); - - if (!result) { - throw new Error('Install transaction did not produce a result'); - } - return result; -} - -export async function resyncInstalledAgent(params: { - installedAgentId: string; - userId: string; -}): Promise { - const sql = getDb(); - const rows = await sql` - SELECT id, organization_id, template_agent_id - FROM agents - WHERE id = ${params.installedAgentId} - LIMIT 1 - `; - if (rows.length === 0) { - throw new Error(`Installed agent ${params.installedAgentId} not found`); - } - const row = rows[0] as { - id: string; - organization_id: string; - template_agent_id: string | null; - }; - if (!row.template_agent_id) { - throw new Error( - `Agent ${params.installedAgentId} has no template_agent_id — nothing to re-sync.` - ); - } - return installAgentFromTemplate({ - templateAgentId: row.template_agent_id, - targetOrganizationId: row.organization_id, - userId: params.userId, - }); -} diff --git a/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts b/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts deleted file mode 100644 index cc8e7327e..000000000 --- a/packages/owletto-backend/src/auth/__tests__/subject-identities.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { normalizePhoneE164, phoneToWhatsAppJid } from '../subject-identities'; - -describe('normalizePhoneE164', () => { - it('keeps a clean E.164 number', () => { - expect(normalizePhoneE164('+447123456789')).toBe('+447123456789'); - }); - - it('strips spaces, dashes, parens, and dots', () => { - expect(normalizePhoneE164('+44 (0) 7123-456.789')).toBe('+447123456789'); - expect(normalizePhoneE164(' + 44 7123 456 789 ')).toBe('+447123456789'); - }); - - it('treats leading 00 as international prefix', () => { - expect(normalizePhoneE164('00447123456789')).toBe('+447123456789'); - }); - - it('assumes UK +44 for a national-format number starting with 0', () => { - expect(normalizePhoneE164('07123456789')).toBe('+447123456789'); - }); - - it('rejects strings that do not produce 7-15 digits', () => { - expect(normalizePhoneE164('abc')).toBeNull(); - expect(normalizePhoneE164('+12')).toBeNull(); - expect(normalizePhoneE164('+1234567890123456')).toBeNull(); - }); - - it('handles non-UK E.164 too', () => { - expect(normalizePhoneE164('+15551234567')).toBe('+15551234567'); - expect(normalizePhoneE164('+33612345678')).toBe('+33612345678'); - }); -}); - -describe('phoneToWhatsAppJid', () => { - it('drops the leading + and appends the WhatsApp suffix', () => { - expect(phoneToWhatsAppJid('+447123456789')).toBe('447123456789@s.whatsapp.net'); - expect(phoneToWhatsAppJid('+15551234567')).toBe('15551234567@s.whatsapp.net'); - }); -}); diff --git a/packages/owletto-backend/src/auth/subject-identities.ts b/packages/owletto-backend/src/auth/subject-identities.ts index 0deaa2bd1..dc55503a6 100644 --- a/packages/owletto-backend/src/auth/subject-identities.ts +++ b/packages/owletto-backend/src/auth/subject-identities.ts @@ -1,9 +1,9 @@ /** * Helpers for writing the user's $member entity + entity_identities rows. * - * These let the signup hook + install endpoint populate the identity graph so - * the gateway can later route inbound WhatsApp / Slack / etc. messages back - * to the right user's personal org via a single entity_identities lookup. + * Used by the signup hook to populate the identity graph so the gateway can + * later route inbound messages back to the right user's personal org via a + * single entity_identities lookup. */ import { getDb } from '../db/client'; @@ -102,66 +102,3 @@ export async function provisionMemberAndCoreIdentities( return { memberEntityId }; } - -/** - * Normalize a user-supplied phone string to E.164 (`+447123456789` form). - * - Strips spaces, dashes, parentheses, dots. - * - Accepts leading `+`, `00` (international prefix), or a UK national `0`. - * - Returns null if the result doesn't look like a 7-15 digit E.164 number. - */ -export function normalizePhoneE164(raw: string): string | null { - const cleaned = raw.replace(/[\s\-().]/g, ''); - let digits: string; - if (cleaned.startsWith('+')) { - digits = cleaned.slice(1); - } else if (cleaned.startsWith('00')) { - digits = cleaned.slice(2); - } else if (cleaned.startsWith('0')) { - // UK national format — assume +44 for this product (UK Self Assessment). - digits = `44${cleaned.slice(1)}`; - } else { - digits = cleaned; - } - // Drop the UK trunk-prefix "0" that often appears as `+44 (0) 71234...` - // after we've stripped parens. UK numbers in E.164 are 12 digits (44 + 10). - if (digits.startsWith('440') && digits.length === 13) { - digits = `44${digits.slice(3)}`; - } - if (!/^\d{7,15}$/.test(digits)) return null; - return `+${digits}`; -} - -/** - * Convert an E.164 phone (e.g. `+447123456789`) to a WhatsApp JID - * (`447123456789@s.whatsapp.net`). Group chats (`@g.us`) are out of scope — - * we only link individual users. - */ -export function phoneToWhatsAppJid(e164: string): string { - return `${e164.slice(1)}@s.whatsapp.net`; -} - -/** - * Attach a WhatsApp identity to the user's $member entity in their personal - * org. Idempotent. Returns the canonical phone + jid that were written so the - * caller can echo them back for confirmation. - */ -export async function linkWhatsAppToMember(params: { - organizationId: string; - email: string; - rawPhone: string; -}): Promise<{ phone: string; waJid: string } | { error: 'invalid_phone' | 'no_member' }> { - const phone = normalizePhoneE164(params.rawPhone); - if (!phone) return { error: 'invalid_phone' }; - const waJid = phoneToWhatsAppJid(phone); - - const sql = getDb(); - const memberEntityId = await findMemberEntityIdByEmail(sql, params.organizationId, params.email); - if (memberEntityId === null) return { error: 'no_member' }; - - await writeIdentities(sql, params.organizationId, memberEntityId, 'install:whatsapp', [ - { namespace: 'phone', identifier: phone }, - { namespace: 'wa_jid', identifier: waJid }, - ]); - - return { phone, waJid }; -} diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index fbe0b3213..2ec448df5 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -23,8 +23,6 @@ import { connectRoutes } from './connect/routes'; import { getDb } from './db/client'; import * as invalidationEmitter from './events/emitter'; import { isExcludedSpaPath } from './http/spa-route-filter'; -import { installManifestRoutes } from './agents/install-manifest-routes'; -import { installRoutes } from './agents/install-routes'; import { agentRoutes } from './lobu/agent-routes'; import { clientRoutes, platformSchemaRoutes } from './lobu/client-routes'; import { isLobuGatewayRunning } from './lobu/gateway'; @@ -439,14 +437,6 @@ app.on(['GET', 'POST'], '/api/auth/*', async (c) => { */ app.route('/api', credentialRoutes); -/** - * Template agent installation routes - * GET /api/install/manifest/:slug — public discovery for a landing page - * POST /api/install — install a template agent into the caller's personal org - */ -app.route('/api', installManifestRoutes); -app.route('/api', installRoutes); - /** * OAuth 2.1 Authorization Server routes * Provides MCP authentication for HTTP clients (Claude.ai, ChatGPT)