diff --git a/packages/owletto-backend/src/__tests__/integration/mcp/public-org-join.test.ts b/packages/owletto-backend/src/__tests__/integration/mcp/public-org-join.test.ts new file mode 100644 index 000000000..7d6c786ac --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/mcp/public-org-join.test.ts @@ -0,0 +1,337 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getTestDb, cleanupTestDatabase } from '../../setup/test-db'; +import { + createTestAccessToken, + createTestOAuthClient, + createTestOrganization, + createTestSession, + createTestUser, + seedSystemEntityTypes, +} from '../../setup/test-fixtures'; +import { get, post } from '../../setup/test-helpers'; + +describe('Public org read access + self-serve join', () => { + let publicOrg: Awaited>; + let privateOrg: Awaited>; + let outsiderUser: Awaited>; + let outsiderSessionCookie: string; + let client: Awaited>; + + beforeAll(async () => { + await cleanupTestDatabase(); + await seedSystemEntityTypes(); + publicOrg = await createTestOrganization({ + name: 'Public Join Org', + slug: 'public-join-org', + description: 'Anyone can read', + visibility: 'public', + }); + privateOrg = await createTestOrganization({ + name: 'Private Join Org', + slug: 'private-join-org', + visibility: 'private', + }); + outsiderUser = await createTestUser({ email: 'outsider@test.example.com' }); + outsiderSessionCookie = (await createTestSession(outsiderUser.id)).cookieHeader; + client = await createTestOAuthClient(); + }); + + async function initializeScopedSession(path: string, token: string) { + const initResponse = await post(path, { + body: { + jsonrpc: '2.0', + id: '__test_init__', + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'owletto-test', version: '1.0' }, + }, + }, + token, + }); + const sessionId = initResponse.headers.get('mcp-session-id'); + expect(sessionId).toBeTruthy(); + await post(path, { + body: { jsonrpc: '2.0', method: 'notifications/initialized' }, + headers: { 'mcp-session-id': sessionId! }, + token, + }); + return sessionId!; + } + + // (a) non-member reads on public org succeed via public/* routes + describe('public/* REST endpoints', () => { + it('returns sanitized organization metadata to anonymous callers', async () => { + const response = await get(`/api/${publicOrg.slug}/public/organization`); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.organization.slug).toBe(publicOrg.slug); + expect(body.organization.name).toBe(publicOrg.name); + expect(body.organization.visibility).toBe('public'); + expect(body.organization).toHaveProperty('agent_count'); + expect(body.organization).toHaveProperty('entity_type_count'); + expect(body.organization).not.toHaveProperty('members'); + }); + + it('returns agent list without credentials to anonymous callers', async () => { + const response = await get(`/api/${publicOrg.slug}/public/agents`); + expect(response.status).toBe(200); + const body = await response.json(); + expect(Array.isArray(body.agents)).toBe(true); + for (const agent of body.agents) { + expect(agent).not.toHaveProperty('auth_profile_id'); + expect(agent).not.toHaveProperty('mcp_servers'); + expect(agent).not.toHaveProperty('config'); + } + }); + + it('404s when the org is private (no leak of existence)', async () => { + const response = await get(`/api/${privateOrg.slug}/public/organization`); + expect(response.status).toBe(404); + }); + }); + + // (b) self-serve join inserts member + (d) duplicate join idempotent + describe('POST /api/:orgSlug/join', () => { + it('requires an authenticated session', async () => { + const response = await post(`/api/${publicOrg.slug}/join`, { body: {} }); + expect(response.status).toBe(401); + }); + + it('inserts a member row with role=member and is idempotent on re-call', async () => { + const firstResponse = await post(`/api/${publicOrg.slug}/join`, { + body: {}, + cookie: outsiderSessionCookie, + }); + expect(firstResponse.status).toBe(200); + const first = await firstResponse.json(); + expect(first.status).toBe('joined'); + expect(first.role).toBe('member'); + expect(first.organizationId).toBe(publicOrg.id); + + const sql = getTestDb(); + const rows = await sql` + SELECT role FROM "member" + WHERE "organizationId" = ${publicOrg.id} AND "userId" = ${outsiderUser.id} + `; + expect(rows).toHaveLength(1); + expect(rows[0].role).toBe('member'); + + // (d) duplicate join returns already_member without inserting a second row + const secondResponse = await post(`/api/${publicOrg.slug}/join`, { + body: {}, + cookie: outsiderSessionCookie, + }); + expect(secondResponse.status).toBe(200); + const second = await secondResponse.json(); + expect(second.status).toBe('already_member'); + expect(second.role).toBe('member'); + + const rowsAfter = await sql` + SELECT id FROM "member" + WHERE "organizationId" = ${publicOrg.id} AND "userId" = ${outsiderUser.id} + `; + expect(rowsAfter).toHaveLength(1); + }); + + // (c) join on private org 403s + it('403s when the workspace is private', async () => { + const otherUser = await createTestUser({ email: 'private-joiner@test.example.com' }); + const cookie = (await createTestSession(otherUser.id)).cookieHeader; + const response = await post(`/api/${privateOrg.slug}/join`, { + body: {}, + cookie, + }); + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe('forbidden'); + }); + + it('404s for an unknown slug', async () => { + const response = await post('/api/does-not-exist-xyz/join', { + body: {}, + cookie: outsiderSessionCookie, + }); + expect(response.status).toBe(404); + }); + }); + + // (e) MCP write tool on public org (non-member) returns new error message + describe('MCP write denial surfaces join_organization', () => { + it('surfaces a join_organization hint when a non-member tries to write', async () => { + const user = await createTestUser({ email: 'mcp-nonmember@test.example.com' }); + const { token } = await createTestAccessToken(user.id, publicOrg.id, client.client_id, { + scope: 'mcp:write profile:read', + }); + const sessionId = await initializeScopedSession(`/mcp/${publicOrg.slug}`, token); + + const response = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'save_knowledge', + arguments: { + content: 'non-member should be denied with join hint', + semantic_type: 'content', + metadata: {}, + }, + }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.result?.isError).toBe(true); + const text = body.result?.content?.[0]?.text ?? ''; + expect(text).toContain('join_organization'); + }); + }); + + // (f) join_organization flips subsequent write from denied to allowed + describe('MCP join_organization tool', () => { + it('upgrades a writeable session so a subsequent entity create succeeds', async () => { + const user = await createTestUser({ email: 'mcp-joiner@test.example.com' }); + const { token } = await createTestAccessToken(user.id, publicOrg.id, client.client_id, { + scope: 'mcp:write profile:read', + }); + const sessionId = await initializeScopedSession(`/mcp/${publicOrg.slug}`, token); + + // Before join: manage_entity:create must be denied (non-member). + const beforeResponse = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'manage_entity', + arguments: { + action: 'create', + name: 'Pre-join Entity', + entity_type: 'brand', + }, + }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const beforeBody = await beforeResponse.json(); + expect(beforeBody.result?.isError).toBe(true); + expect(beforeBody.result?.content?.[0]?.text ?? '').toContain('join_organization'); + + // Join via MCP tool + const joinResponse = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'join_organization', arguments: {} }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const joinBody = await joinResponse.json(); + expect(joinBody.result?.isError).not.toBe(true); + const joinText = joinBody.result?.content?.[0]?.text ?? ''; + const parsed = JSON.parse(joinText); + expect(parsed.status === 'joined' || parsed.status === 'already_member').toBe(true); + expect(parsed.org.role).toBe('member'); + // Write-scoped session shouldn't receive the read-only note + expect(parsed.note).toBeUndefined(); + + // After join: manage_entity:create must succeed. + const afterResponse = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'manage_entity', + arguments: { + action: 'create', + name: 'Post-join Entity', + entity_type: 'brand', + }, + }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const afterBody = await afterResponse.json(); + expect(afterBody.result?.isError).not.toBe(true); + }); + + it('returns a read-only scope note when the session cannot write', async () => { + const user = await createTestUser({ email: 'mcp-readonly-joiner@test.example.com' }); + const { token } = await createTestAccessToken(user.id, publicOrg.id, client.client_id, { + scope: 'mcp:read profile:read', + }); + const sessionId = await initializeScopedSession(`/mcp/${publicOrg.slug}`, token); + + const joinResponse = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'join_organization', arguments: {} }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const joinBody = await joinResponse.json(); + expect(joinBody.result?.isError).not.toBe(true); + const text = joinBody.result?.content?.[0]?.text ?? ''; + const parsed = JSON.parse(text); + expect(parsed.note).toBeDefined(); + expect(parsed.note).toContain('mcp:write'); + }); + + it('rejects join_organization when the token has no mcp:* scope', async () => { + const user = await createTestUser({ email: 'mcp-noscope-joiner@test.example.com' }); + const { token } = await createTestAccessToken(user.id, publicOrg.id, client.client_id, { + scope: 'profile:read', + }); + const sessionId = await initializeScopedSession(`/mcp/${publicOrg.slug}`, token); + + const joinResponse = await post(`/mcp/${publicOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'join_organization', arguments: {} }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const joinBody = await joinResponse.json(); + expect(joinBody.result?.isError).toBe(true); + expect(joinBody.result?.content?.[0]?.text ?? '').toContain('mcp:'); + }); + + it('rejects joining a private workspace via MCP tool', async () => { + const user = await createTestUser({ email: 'mcp-private-joiner@test.example.com' }); + const { token } = await createTestAccessToken(user.id, privateOrg.id, client.client_id, { + scope: 'mcp:write profile:read', + }); + const sessionId = await initializeScopedSession(`/mcp/${privateOrg.slug}`, token); + + const joinResponse = await post(`/mcp/${privateOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'join_organization', arguments: {} }, + }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + const joinBody = await joinResponse.json(); + expect(joinBody.result?.isError).toBe(true); + expect(joinBody.result?.content?.[0]?.text ?? '').toContain('not public'); + }); + }); +}); diff --git a/packages/owletto-backend/src/auth/tool-access.ts b/packages/owletto-backend/src/auth/tool-access.ts index 4303edaf5..6b8ad80f4 100644 --- a/packages/owletto-backend/src/auth/tool-access.ts +++ b/packages/owletto-backend/src/auth/tool-access.ts @@ -60,6 +60,10 @@ const PUBLIC_READ_ACTIONS: Record | null> = { read_knowledge: null, get_watcher: null, list_watchers: null, + // Visible to anonymous/non-member sessions so the LLM can discover the + // self-serve join path on a public workspace. The tool itself enforces + // authentication and public-org policy at call time. + join_organization: null, manage_entity: new Set(['list', 'get', 'list_links']), manage_entity_schema: new Set(['list', 'get', 'audit', 'list_rules']), manage_connections: new Set(['list', 'get', 'list_connector_definitions']), diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index 5052f3e24..d2a776148 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -43,8 +43,11 @@ import { renderPublicPageTemplate, } from './public-pages'; import { + publicRestEventsStream, publicRestGetConnector, + publicRestGetOrganization, publicRestGetWatchers, + publicRestListAgents, publicRestListClassifiers, publicRestListConnectors, publicRestSearchKnowledge, @@ -67,6 +70,7 @@ import { import { getClientIP, getRateLimiter, RateLimitPresets } from './utils/rate-limiter'; import { getRuntimeInfo } from './utils/runtime-info'; import { getWorkspaceProvider } from './workspace'; +import { joinPublicOrganization } from './workspace/join-public'; export type { Env }; @@ -659,6 +663,9 @@ app.get('/api/:orgSlug/public/knowledge/search', publicRestSearchKnowledge); app.get('/api/:orgSlug/public/classifiers', publicRestListClassifiers); app.get('/api/:orgSlug/public/connectors', publicRestListConnectors); app.get('/api/:orgSlug/public/connectors/:connectorKey', publicRestGetConnector); +app.get('/api/:orgSlug/public/organization', publicRestGetOrganization); +app.get('/api/:orgSlug/public/agents', publicRestListAgents); +app.get('/api/:orgSlug/public/events', publicRestEventsStream); app.patch( '/api/:orgSlug/content/:id/classifications/:classifier_slug', mcpAuth, @@ -864,6 +871,56 @@ app.get('/api/features', (c) => { }); }); +/** + * Self-serve join a public organization. Authenticated session required. + * Inserts a member row with role='member' and mirrors Better Auth's + * afterAddMember side effects (see workspace/join-public.ts). + */ +app.post('/api/:orgSlug/join', async (c) => { + const rateLimiter = getRateLimiter(); + const clientIP = getClientIP(c.req.raw); + const rateLimit = rateLimiter.checkLimit( + `rate:join-public-org:${clientIP}`, + RateLimitPresets.JOIN_PUBLIC_ORG_PER_IP_HOUR + ); + if (!rateLimit.allowed) { + return c.json({ error: rateLimit.errorMessage }, 429); + } + + const auth = await createAuth(c.env); + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + const userId = session?.session?.userId; + if (!userId) { + return c.json( + { error: 'unauthorized', error_description: 'Sign in to join a workspace.' }, + 401 + ); + } + + const orgSlug = c.req.param('orgSlug'); + if (!orgSlug) return c.json({ error: 'invalid_request' }, 400); + + const result = await joinPublicOrganization({ userId, orgSlug }); + if (result.status === 'not_found') { + return c.json({ error: 'not_found', error_description: 'Workspace not found.' }, 404); + } + if (result.status === 'not_public') { + return c.json( + { + error: 'forbidden', + error_description: 'This workspace is private. Ask an owner for an invitation.', + }, + 403 + ); + } + + return c.json({ + status: result.status, + organizationId: result.organizationId, + role: result.role, + }); +}); + /** * Generic tool proxy - forwards to any MCP tool * POST /api/:orgSlug/:toolName with JSON body diff --git a/packages/owletto-backend/src/rest-api.ts b/packages/owletto-backend/src/rest-api.ts index 00b413b7d..63b241e5e 100644 --- a/packages/owletto-backend/src/rest-api.ts +++ b/packages/owletto-backend/src/rest-api.ts @@ -7,6 +7,7 @@ import type { Context } from 'hono'; import { getDb } from './db/client'; +import * as invalidationEmitter from './events/emitter'; import type { Env } from './index'; import { EMPTY_SUMMARY, @@ -479,6 +480,143 @@ export async function publicRestGetConnector(c: Context<{ Bindings: Env }>) { }); } +/** + * GET /api/:orgSlug/public/organization + * Sanitized org metadata for non-members of a public workspace. + * No member roster, no internal settings. + */ +export async function publicRestGetOrganization(c: Context<{ Bindings: Env }>) { + return withPublicOrg(c, async (organizationId) => { + const sql = getDb(); + const rows = await sql<{ + id: string; + slug: string; + name: string; + description: string | null; + logo: string | null; + visibility: string; + created_at: string; + }>` + SELECT id, slug, name, description, logo, visibility, "createdAt" AS created_at + FROM "organization" + WHERE id = ${organizationId} + LIMIT 1 + `; + const org = rows[0]; + if (!org) throw new Error('Organization not found'); + + const [{ count: agent_count }] = await sql<{ count: number }>` + SELECT COUNT(*)::int AS count FROM agents + WHERE organization_id = ${organizationId} + AND parent_connection_id IS NULL + `; + const [{ count: entity_type_count }] = await sql<{ count: number }>` + SELECT COUNT(*)::int AS count FROM entity_types + WHERE organization_id = ${organizationId} + AND deleted_at IS NULL + `; + return { + organization: { + ...org, + agent_count, + entity_type_count, + }, + }; + }); +} + +/** + * GET /api/:orgSlug/public/agents + * Sanitized agent list for non-members of a public workspace. + * Only name/description/id are exposed — no credentials, MCP server URLs, + * auth profiles, or configuration. + */ +export async function publicRestListAgents(c: Context<{ Bindings: Env }>) { + return withPublicOrg(c, async (organizationId) => { + const sql = getDb(); + const rows = await sql<{ + id: string; + name: string; + description: string | null; + created_at: string; + }>` + SELECT id, name, description, created_at + FROM agents + WHERE organization_id = ${organizationId} + AND parent_connection_id IS NULL + ORDER BY created_at ASC + `; + return { agents: rows }; + }); +} + +/** + * Cache keys safe to forward to anonymous / non-member viewers of a public org. + * Must exclude notifications, member-admin, and connector-admin events. + */ +const PUBLIC_INVALIDATION_KEYS = new Set([ + 'resolve-path', + 'entity-types', + 'view-template-history', + 'contents-filtered', +]); + +/** + * GET /api/:orgSlug/public/events + * SSE stream of cache invalidation events for non-members of a public workspace. + * Only public-readable keys are forwarded; notifications / member / connector + * admin invalidations are filtered out. + */ +export async function publicRestEventsStream(c: Context<{ Bindings: Env }>) { + const orgSlug = c.req.param('orgSlug'); + if (!orgSlug) return c.json({ error: 'Organization slug is required' }, 400); + + const organizationId = await resolvePublicOrganizationId(orgSlug); + if (!organizationId) return c.json({ error: 'Not found' }, 404); + + const encoder = new TextEncoder(); + let cleanup: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n')); + + const unsubscribe = invalidationEmitter.subscribe(organizationId, (event) => { + const publicKeys = event.keys.filter((k) => PUBLIC_INVALIDATION_KEYS.has(k)); + if (publicKeys.length === 0) return; + try { + const data = JSON.stringify({ ...event, keys: publicKeys }); + controller.enqueue(encoder.encode(`event: invalidate\ndata: ${data}\n\n`)); + } catch { + // Connection closed + } + }); + + const keepAlive = setInterval(() => { + try { + controller.enqueue(encoder.encode(': keepalive\n\n')); + } catch { + clearInterval(keepAlive); + } + }, 30000); + + cleanup = () => { + unsubscribe(); + clearInterval(keepAlive); + }; + }, + cancel() { + cleanup?.(); + }, + }); + + c.header('Content-Type', 'text/event-stream'); + c.header('Cache-Control', 'no-cache'); + c.header('Connection', 'keep-alive'); + + return c.body(stream); +} + /** * PATCH /api/content/:id/classifications/:classifier_slug * Update a single content item's classification manually diff --git a/packages/owletto-backend/src/tools/execute.ts b/packages/owletto-backend/src/tools/execute.ts index ca8faf389..9c973c9af 100644 --- a/packages/owletto-backend/src/tools/execute.ts +++ b/packages/owletto-backend/src/tools/execute.ts @@ -9,7 +9,7 @@ import { getRequiredAccessLevel, isPublicReadable } from '../auth/tool-access'; import type { Env } from '../index'; import { trackMCPToolCall } from '../sentry'; import { getConfiguredPublicOrigin } from '../utils/public-origin'; -import { listOrganizations, switchOrganization } from './organizations'; +import { joinOrganization, listOrganizations, switchOrganization } from './organizations'; import { getTool, type ToolContext } from './registry'; /** @@ -95,8 +95,13 @@ export function checkToolAccess(toolName: string, args: unknown, authCtx: AuthCo const requiredAccess = getRequiredAccessLevel(toolName, args, isReadOnly); if (!role && !isPublicReadable(toolName, args)) { + if (authCtx.userId) { + throw new Error( + 'This public workspace is read-only for your account. Call the `join_organization` tool to become a member and unlock write access (or join via the web UI).' + ); + } throw new Error( - 'This public workspace is read-only for your account. Ask an organization admin to invite you for write access.' + 'This public workspace is read-only for anonymous access. Sign in with an OAuth client that has write access, then call `join_organization` to become a member.' ); } @@ -116,7 +121,7 @@ export function checkToolAccess(toolName: string, args: unknown, authCtx: AuthCo } if (requiredAccess === 'write') { throw new Error( - 'This MCP session is read-only. Reconnect with write access after you are added to the organization.' + 'This MCP session is read-only. If this is a public workspace, call `join_organization` first, then reconnect with write-scoped OAuth. Otherwise ask an owner to add you.' ); } throw new Error( @@ -135,6 +140,29 @@ export async function executeTool( env: Env, authCtx: AuthContext ): Promise { + // join_organization bypasses the standard membership/role check — its whole + // purpose is to upgrade a non-member / read-only session into a member. + // It still requires the caller to hold at least mcp:read, so OAuth tokens + // without any MCP scope cannot change membership. The tool itself enforces + // public-org policy. + if (toolName === 'join_organization') { + if (!authCtx.userId) { + throw new Error('Authentication required to join an organization. Sign in with OAuth first.'); + } + if (!hasRequiredMcpScope('read', authCtx.scopes)) { + throw new Error( + 'This MCP session does not include any mcp:* scope. Reconnect with at least read access before calling `join_organization`.' + ); + } + return trackMCPToolCall(toolName, args, () => + joinOrganization(args as any, env, { + userId: authCtx.userId!, + currentOrgId: authCtx.organizationId, + scopes: authCtx.scopes ?? null, + }) + ); + } + checkToolAccess(toolName, args, authCtx); // Org-agnostic tools get a minimal context with just userId diff --git a/packages/owletto-backend/src/tools/organizations.ts b/packages/owletto-backend/src/tools/organizations.ts index 7cc97c10d..15162c8a7 100644 --- a/packages/owletto-backend/src/tools/organizations.ts +++ b/packages/owletto-backend/src/tools/organizations.ts @@ -10,6 +10,7 @@ import { getDb } from '../db/client'; import type { Env } from '../index'; import { buildWorkspaceInstructions } from '../utils/workspace-instructions'; import { getWorkspaceProvider } from '../workspace'; +import { joinPublicOrganization } from '../workspace/join-public'; import type { OrgInfo } from '../workspace/types'; // --------------------------------------------------------------------------- @@ -30,6 +31,16 @@ export const SwitchOrganizationSchema = Type.Object({ }), }); +export const JoinOrganizationSchema = Type.Object({ + organization_slug: Type.Optional( + Type.String({ + description: + 'Organization slug to join. Optional on scoped /mcp/{slug} sessions (defaults to the current workspace). Required on the unscoped /mcp endpoint.', + minLength: 1, + }) + ), +}); + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -97,3 +108,58 @@ export async function switchOrganization( instructions, }; } + +export async function joinOrganization( + args: Static, + _env: Env, + ctx: { userId: string; currentOrgId: string | null; scopes: string[] | null } +): Promise<{ + status: 'joined' | 'already_member'; + org: { slug: string; name: string; id: string; role: string }; + note?: string; +}> { + let slug = args.organization_slug ?? null; + if (!slug) { + if (!ctx.currentOrgId) { + throw new Error( + 'organization_slug is required when calling join_organization on the unscoped /mcp endpoint.' + ); + } + slug = await getWorkspaceProvider().getOrgSlug(ctx.currentOrgId); + if (!slug) { + throw new Error('Could not resolve the current organization slug.'); + } + } + + const result = await joinPublicOrganization({ userId: ctx.userId, orgSlug: slug }); + if (result.status === 'not_found') { + throw new Error(`Organization '${slug}' not found.`); + } + if (result.status === 'not_public') { + throw new Error( + `Organization '${slug}' is not public. Ask an organization owner for an invitation.` + ); + } + + const sql = getDb(); + const rows = await sql<{ name: string }>` + SELECT name FROM "organization" WHERE id = ${result.organizationId} LIMIT 1 + `; + const name = rows[0]?.name ?? slug; + + const scopes = ctx.scopes; + const readOnlyScopes = + Array.isArray(scopes) && + scopes.length > 0 && + !scopes.includes('mcp:write') && + !scopes.includes('mcp:admin'); + const note = readOnlyScopes + ? 'Your current OAuth session is read-only. Reconnect with write access (mcp:write) to push data to this workspace.' + : undefined; + + return { + status: result.status, + org: { slug, name, id: result.organizationId, role: result.role }, + ...(note ? { note } : {}), + }; +} diff --git a/packages/owletto-backend/src/tools/registry.ts b/packages/owletto-backend/src/tools/registry.ts index 5ceb85bb6..bb6883b50 100644 --- a/packages/owletto-backend/src/tools/registry.ts +++ b/packages/owletto-backend/src/tools/registry.ts @@ -12,7 +12,11 @@ import { ADMIN_TOOLS } from './admin'; import { ListWatchersSchema, listWatchers } from './admin/manage_watchers'; import { GetContentSchema, getContent } from './get_content'; import { GetWatcherSchema, getWatcher } from './get_watchers'; -import { ListOrganizationsSchema, SwitchOrganizationSchema } from './organizations'; +import { + JoinOrganizationSchema, + ListOrganizationsSchema, + SwitchOrganizationSchema, +} from './organizations'; import { ResolvePathSchema, resolvePath } from './resolve_path'; import { SaveContentSchema, saveContent } from './save_content'; // Import tool implementations and their schemas @@ -161,6 +165,18 @@ WATCHER MODE: When \`watcher_id\` is provided with \`since\`/\`until\`, returns throw new Error('Handled directly in executeTool'); }, }, + { + name: 'join_organization', + description: + 'Join the current public workspace as a member so you can write (create entities, save knowledge, update classifications). Only works on workspaces with visibility=public; private workspaces still require an invitation. Idempotent — calling when already a member is safe. On the unscoped /mcp endpoint, pass `organization_slug`; on /mcp/{slug} sessions the current workspace is used.', + inputSchema: JoinOrganizationSchema, + // readOnlyHint:true lets read-scoped MCP sessions invoke it — the whole + // point of this tool is to flip an anonymous reader into a member. + annotations: { readOnlyHint: true, idempotentHint: true }, + handler: async () => { + throw new Error('Handled directly in executeTool'); + }, + }, // Admin tools ...ADMIN_TOOLS, ]; diff --git a/packages/owletto-backend/src/utils/rate-limiter.ts b/packages/owletto-backend/src/utils/rate-limiter.ts index ef76ac29b..130e3b594 100644 --- a/packages/owletto-backend/src/utils/rate-limiter.ts +++ b/packages/owletto-backend/src/utils/rate-limiter.ts @@ -172,6 +172,13 @@ export const RateLimitPresets = { windowSeconds: 60, errorMessage: 'Too many invitation lookups. Try again shortly.', } as RateLimitConfig, + + /** Self-serve join public org: 10/hour per IP */ + JOIN_PUBLIC_ORG_PER_IP_HOUR: { + limit: 10, + windowSeconds: 3600, + errorMessage: 'Join rate limit exceeded. Maximum 10 join attempts per hour.', + } as RateLimitConfig, }; /** Module-level singleton rate limiter. */ diff --git a/packages/owletto-backend/src/workspace/join-public.ts b/packages/owletto-backend/src/workspace/join-public.ts new file mode 100644 index 000000000..4b8a0fa07 --- /dev/null +++ b/packages/owletto-backend/src/workspace/join-public.ts @@ -0,0 +1,92 @@ +import { generateSecureToken } from '../auth/oauth/utils'; +import { getDb } from '../db/client'; +import { ensureMemberEntity } from '../utils/member-entity'; +import { invalidateMembershipRoleCache } from './multi-tenant'; + +export type JoinPublicResult = + | { status: 'joined' | 'already_member'; organizationId: string; role: string } + | { status: 'not_found' } + | { status: 'not_public' }; + +interface JoinPublicParams { + userId: string; + orgSlug: string; +} + +/** + * Self-serve join for a public organization. Used by the REST /join endpoint + * and the join_organization MCP tool. Idempotent. + * + * Replicates the side effects of Better Auth's afterAddMember hook + * (ensureMemberEntity + invalidateMembershipRoleCache) since Better Auth's + * addMember API is admin-gated and can't be used for self-service. + */ +export async function joinPublicOrganization({ + userId, + orgSlug, +}: JoinPublicParams): Promise { + const sql = getDb(); + + const orgRows = await sql<{ + id: string; + visibility: string; + }>` + SELECT id, visibility FROM "organization" + WHERE slug = ${orgSlug} + LIMIT 1 + `; + if (orgRows.length === 0) return { status: 'not_found' }; + const { id: organizationId, visibility } = orgRows[0]; + if (visibility !== 'public') return { status: 'not_public' }; + + const existing = await sql<{ role: string }>` + SELECT role FROM "member" + WHERE "organizationId" = ${organizationId} AND "userId" = ${userId} + LIMIT 1 + `; + if (existing.length > 0) { + return { + status: 'already_member', + organizationId, + role: existing[0].role, + }; + } + + const userRows = await sql<{ + id: string; + name: string; + email: string; + image: string | null; + }>` + SELECT id, name, email, image FROM "user" + WHERE id = ${userId} + LIMIT 1 + `; + if (userRows.length === 0) return { status: 'not_found' }; + const user = userRows[0]; + + const memberId = `member_${generateSecureToken(8)}`; + await sql` + INSERT INTO "member" (id, "organizationId", "userId", role, "createdAt") + VALUES (${memberId}, ${organizationId}, ${userId}, 'member', NOW()) + ON CONFLICT ("organizationId", "userId") DO NOTHING + `; + + try { + await ensureMemberEntity({ + organizationId, + userId, + name: user.name || user.email, + email: user.email, + image: user.image ?? undefined, + role: 'member', + status: 'active', + }); + } catch (err) { + console.error('[joinPublicOrganization] Failed to create $member entity:', err); + } + + invalidateMembershipRoleCache(organizationId, userId); + + return { status: 'joined', organizationId, role: 'member' }; +}