diff --git a/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts new file mode 100644 index 000000000..1d548dea1 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ensureMemberEntityType } from '../../../utils/member-entity-type'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + createTestOrganization, + createTestSession, + createTestUser, + seedSystemEntityTypes, +} from '../../setup/test-fixtures'; +import { post } from '../../setup/test-helpers'; + +describe('$member visibility policy on public orgs', () => { + let publicOrg: Awaited>; + let adminUser: Awaited>; + let memberUser: Awaited>; + let adminCookie: string; + let memberCookie: string; + let outsiderCookie: string; + + const ADMIN_EMAIL = 'admin-redaction@test.example.com'; + const MEMBER_EMAIL = 'plain-member@test.example.com'; + + beforeAll(async () => { + await cleanupTestDatabase(); + await seedSystemEntityTypes(); + + publicOrg = await createTestOrganization({ + name: 'Member Redaction Public Org', + slug: 'member-redaction-public', + visibility: 'public', + }); + + adminUser = await createTestUser({ email: ADMIN_EMAIL }); + memberUser = await createTestUser({ email: MEMBER_EMAIL }); + adminCookie = (await createTestSession(adminUser.id)).cookieHeader; + memberCookie = (await createTestSession(memberUser.id)).cookieHeader; + + await ensureMemberEntityType(publicOrg.id); + + const sql = getTestDb(); + await sql` + INSERT INTO "member" (id, "organizationId", "userId", role, "createdAt") + VALUES + (gen_random_uuid()::text, ${publicOrg.id}, ${adminUser.id}, 'owner', NOW()), + (gen_random_uuid()::text, ${publicOrg.id}, ${memberUser.id}, 'member', NOW()) + ON CONFLICT DO NOTHING + `; + + await sql` + INSERT INTO entities ( + name, slug, entity_type, organization_id, metadata, created_by, created_at, updated_at + ) VALUES ( + 'Plain Member', + 'plain-member', + '$member', + ${publicOrg.id}, + ${sql.json({ email: MEMBER_EMAIL, status: 'active', role: 'member' })}, + ${adminUser.id}, + NOW(), NOW() + ) + `; + + const outsider = await createTestUser({ email: 'nonmember-nomercy@test.example.com' }); + outsiderCookie = (await createTestSession(outsider.id)).cookieHeader; + }); + + async function listMembers(cookie?: string) { + return post(`/api/${publicOrg.slug}/manage_entity`, { + body: { action: 'list', entity_type: '$member', limit: 50, offset: 0 }, + cookie, + }); + } + + it('refuses the member list to anonymous callers', async () => { + const response = await listMembers(); + expect(response.status).toBe(400); + const body = await response.json(); + expect(String(body.error)).toMatch(/only visible to members/i); + }); + + it('refuses the member list to authenticated non-members', async () => { + const response = await listMembers(outsiderCookie); + expect(response.status).toBe(400); + const body = await response.json(); + expect(String(body.error)).toMatch(/only visible to members/i); + }); + + it('returns members without email to regular members', async () => { + const response = await listMembers(memberCookie); + expect(response.status).toBe(200); + const body = await response.json(); + const hit = body.entities.find((e: any) => e.name === 'Plain Member'); + expect(hit).toBeTruthy(); + expect(hit.metadata).not.toHaveProperty('email'); + // Non-PII fields stay visible so the list view still renders useful columns. + expect(hit.metadata.status).toBe('active'); + }); + + it('returns member emails to admin/owner callers', async () => { + const response = await listMembers(adminCookie); + expect(response.status).toBe(200); + const body = await response.json(); + const hit = body.entities.find((e: any) => e.name === 'Plain Member'); + expect(hit).toBeTruthy(); + expect(hit.metadata.email).toBe(MEMBER_EMAIL); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts new file mode 100644 index 000000000..8ccd3d310 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts @@ -0,0 +1,423 @@ +/** + * Page Auth Coverage + * + * Covers the class of bug where a page stays stuck on "Loading..." because a + * backend read endpoint returns null for a resource the frontend expects to + * exist. Exercises the core MCP tools each UI page hits under anonymous, + * member, and owner auth states against both public and private workspaces. + * + * Uses scoped `/mcp/:orgSlug` sessions throughout (same pattern as + * public-org-join.test.ts) so multiple tokens can coexist without sharing + * the default-MCP session cache keyed by token alone. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestAccessToken, + createTestEntity, + createTestOAuthClient, + createTestOrganization, + createTestUser, + seedSystemEntityTypes, + type TestOAuthClient, + type TestOrganization, + type TestUser, +} from '../../setup/test-fixtures'; +import { get, post } from '../../setup/test-helpers'; + +async function initializeScopedSession(path: string, token: string): Promise { + 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'); + if (!sessionId) { + throw new Error( + `MCP initialize did not return session ID (status=${initResponse.status})` + ); + } + await post(path, { + body: { jsonrpc: '2.0', method: 'notifications/initialized' }, + headers: { 'mcp-session-id': sessionId }, + token, + }); + return sessionId; +} + +interface ToolCallArgs { + orgSlug: string; + sessionId: string; + token: string; + name: string; + args: Record; +} + +async function callTool({ orgSlug, sessionId, token, name, args }: ToolCallArgs) { + const response = await post(`/mcp/${orgSlug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args }, + }, + // X-MCP-Format: json returns raw JSON text instead of markdown-wrapped output + headers: { 'mcp-session-id': sessionId, 'X-MCP-Format': 'json' }, + token, + }); + return response.json(); +} + +function parseToolResult(body: { result?: { content?: Array<{ text: string }> } }) { + const text = body.result?.content?.[0]?.text ?? '{}'; + return JSON.parse(text); +} + +async function freshSession( + org: TestOrganization, + user: TestUser, + client: TestOAuthClient, + scope = 'mcp:read mcp:write' +) { + const { token } = await createTestAccessToken(user.id, org.id, client.client_id, { scope }); + const sessionId = await initializeScopedSession(`/mcp/${org.slug}`, token); + return { token, sessionId }; +} + +describe('Page auth coverage', () => { + let publicOrg: TestOrganization; + let privateOrg: TestOrganization; + let owner: TestUser; + let member: TestUser; + let outsider: TestUser; + let client: TestOAuthClient; + + beforeAll(async () => { + await cleanupTestDatabase(); + await seedSystemEntityTypes(); + + publicOrg = await createTestOrganization({ + name: 'Public Page Org', + slug: 'public-page-org', + visibility: 'public', + }); + privateOrg = await createTestOrganization({ + name: 'Private Page Org', + slug: 'private-page-org', + visibility: 'private', + }); + + owner = await createTestUser({ email: 'page-owner@test.example.com' }); + member = await createTestUser({ email: 'page-member@test.example.com' }); + outsider = await createTestUser({ email: 'page-outsider@test.example.com' }); + + await addUserToOrganization(owner.id, publicOrg.id, 'owner'); + await addUserToOrganization(member.id, publicOrg.id, 'member'); + await addUserToOrganization(owner.id, privateOrg.id, 'owner'); + + client = await createTestOAuthClient(); + + // Entity types are per-org, so seed one directly into publicOrg so the + // list/get tests below have something to find. Matches the shape the + // lifecycle test writes via `manage_entity_schema create`. + const sql = getTestDb(); + await sql` + INSERT INTO entity_types ( + organization_id, slug, name, description, icon, + metadata_schema, created_at, updated_at + ) VALUES ( + ${publicOrg.id}, 'brand', 'Brand', 'Brand for tests', '🏢', + ${sql.json({ type: 'object', additionalProperties: true })}, + NOW(), NOW() + ) + `; + + await createTestEntity({ + name: 'Public Brand', + entity_type: 'brand', + organization_id: publicOrg.id, + created_by: owner.id, + }); + }); + + // ------------------------------------------------------------ + // Regression: $member entity type lazily provisions on first GET. + // Original bug: etHandleGet returned entity_type=null which left + // //%24member stuck on "Loading..." indefinitely. + // ------------------------------------------------------------ + describe('manage_entity_schema get $member (regression)', () => { + it('auto-provisions the $member entity type on first access', async () => { + const sql = getTestDb(); + const fresh = await createTestOrganization({ name: 'Fresh Members Org' }); + const freshOwner = await createTestUser({ email: 'fresh-owner@test.example.com' }); + await addUserToOrganization(freshOwner.id, fresh.id, 'owner'); + const { token, sessionId } = await freshSession(fresh, freshOwner, client); + + const before = await sql` + SELECT id FROM entity_types + WHERE slug = '$member' AND organization_id = ${fresh.id} + `; + expect(before).toHaveLength(0); + + const body = await callTool({ + orgSlug: fresh.slug, + sessionId, + token, + name: 'manage_entity_schema', + args: { schema_type: 'entity_type', action: 'get', slug: '$member' }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.entity_type).not.toBeNull(); + expect(result.entity_type.slug).toBe('$member'); + expect(result.entity_type.metadata_schema).toBeDefined(); + expect(result.entity_type.event_kinds).toBeDefined(); + + const after = await sql` + SELECT id FROM entity_types + WHERE slug = '$member' AND organization_id = ${fresh.id} + `; + expect(after).toHaveLength(1); + + // Second call returns the same row without throwing or re-inserting. + const body2 = await callTool({ + orgSlug: fresh.slug, + sessionId, + token, + name: 'manage_entity_schema', + args: { schema_type: 'entity_type', action: 'get', slug: '$member' }, + }); + const result2 = parseToolResult(body2); + expect(result2.entity_type?.slug).toBe('$member'); + + const afterSecond = await sql` + SELECT id FROM entity_types + WHERE slug = '$member' AND organization_id = ${fresh.id} + `; + expect(afterSecond).toHaveLength(1); + }); + + it('still returns null for unknown non-reserved slugs', async () => { + const { token, sessionId } = await freshSession(publicOrg, owner, client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'manage_entity_schema', + args: { schema_type: 'entity_type', action: 'get', slug: 'does-not-exist-xyz' }, + }); + const result = parseToolResult(body); + expect(result.entity_type).toBeNull(); + }); + }); + + // ------------------------------------------------------------ + // resolve_path — the single call OwnerResolver makes for every + // workspace page. If this fails the entire app stays on "Loading...". + // ------------------------------------------------------------ + describe('resolve_path', () => { + for (const [label, getUser] of [ + ['owner', () => owner], + ['member', () => member], + ] as const) { + it(`resolves the workspace home as ${label}`, async () => { + const { token, sessionId } = await freshSession(publicOrg, getUser(), client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'resolve_path', + args: { path: `/${publicOrg.slug}` }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.workspace?.slug).toBe(publicOrg.slug); + }); + + it(`resolves an entity detail path as ${label}`, async () => { + const { token, sessionId } = await freshSession(publicOrg, getUser(), client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'resolve_path', + args: { path: `/${publicOrg.slug}/brand/public-brand` }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.entity?.name).toBe('Public Brand'); + }); + } + }); + + // ------------------------------------------------------------ + // manage_entity_schema list/get — sidebar, entity-type list, + // member detail page. list must not return an empty array when + // the org has system types. + // ------------------------------------------------------------ + describe('manage_entity_schema list/get', () => { + for (const [label, getUser] of [ + ['owner', () => owner], + ['member', () => member], + ] as const) { + it(`returns system types as ${label}`, async () => { + const { token, sessionId } = await freshSession(publicOrg, getUser(), client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'manage_entity_schema', + args: { schema_type: 'entity_type', action: 'list' }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(Array.isArray(result.entity_types)).toBe(true); + expect(result.entity_types.length).toBeGreaterThan(0); + expect(result.entity_types.some((t: { slug: string }) => t.slug === 'brand')).toBe(true); + }); + + it(`returns a concrete entity_type for 'brand' as ${label}`, async () => { + const { token, sessionId } = await freshSession(publicOrg, getUser(), client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'manage_entity_schema', + args: { schema_type: 'entity_type', action: 'get', slug: 'brand' }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.entity_type).not.toBeNull(); + expect(result.entity_type.slug).toBe('brand'); + }); + } + }); + + // ------------------------------------------------------------ + // manage_entity list — entity list pages must not silently return + // an empty payload when the org has entities. + // ------------------------------------------------------------ + describe('manage_entity list', () => { + for (const [label, getUser] of [ + ['owner', () => owner], + ['member', () => member], + ] as const) { + it(`lists brand entities as ${label}`, async () => { + const { token, sessionId } = await freshSession(publicOrg, getUser(), client); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'manage_entity', + args: { action: 'list', entity_type: 'brand' }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(Array.isArray(result.entities)).toBe(true); + expect(result.entities.length).toBeGreaterThan(0); + }); + } + }); + + // ------------------------------------------------------------ + // Anonymous reads — unauthenticated users landing on a public + // org should render the public home without a sign-in redirect. + // ------------------------------------------------------------ + describe('anonymous access', () => { + it('public/organization returns 200 for public org', 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); + }); + + it('public/organization returns 404 for private org (no existence leak)', async () => { + const response = await get(`/api/${privateOrg.slug}/public/organization`); + expect(response.status).toBe(404); + }); + + it('public/agents returns 200 for public org', 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); + }); + }); + + // ------------------------------------------------------------ + // Scoped MCP read on a public org as a non-member — mirrors what + // the members page does before the user joins: the workspace + // has to be browsable and read-only tool calls should succeed. + // ------------------------------------------------------------ + describe('non-member scoped read on public org', () => { + it('permits resolve_path as a non-member via /mcp/:orgSlug', async () => { + const { token, sessionId } = await freshSession( + publicOrg, + outsider, + client, + 'mcp:read profile:read' + ); + const body = await callTool({ + orgSlug: publicOrg.slug, + sessionId, + token, + name: 'resolve_path', + args: { path: `/${publicOrg.slug}` }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.workspace?.slug).toBe(publicOrg.slug); + }); + + it('rejects MCP initialize against a private org', async () => { + const { token } = await createTestAccessToken(outsider.id, privateOrg.id, client.client_id, { + scope: 'mcp:read profile:read', + }); + const response = await post(`/mcp/${privateOrg.slug}`, { + body: { + jsonrpc: '2.0', + id: '__test_init__', + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'owletto-test', version: '1.0' }, + }, + }, + token, + }); + expect([401, 403, 404]).toContain(response.status); + }); + }); + + // ------------------------------------------------------------ + // Owner token against private org continues to work — regression + // sanity check that private-org tooling didn't get broken by any + // public-org plumbing. + // ------------------------------------------------------------ + describe('private org owner retains full access', () => { + it('resolves the private org home for its owner', async () => { + const { token, sessionId } = await freshSession(privateOrg, owner, client); + const body = await callTool({ + orgSlug: privateOrg.slug, + sessionId, + token, + name: 'resolve_path', + args: { path: `/${privateOrg.slug}` }, + }); + expect(body.result?.isError).not.toBe(true); + const result = parseToolResult(body); + expect(result.workspace?.slug).toBe(privateOrg.slug); + }); + }); +}); diff --git a/packages/owletto-backend/src/public-pages.ts b/packages/owletto-backend/src/public-pages.ts index ef2c2e4e9..113e105dd 100644 --- a/packages/owletto-backend/src/public-pages.ts +++ b/packages/owletto-backend/src/public-pages.ts @@ -107,6 +107,7 @@ function buildToolContext(requestUrl: string, organizationId: string): ToolConte return { organizationId, userId: null, + memberRole: null, isAuthenticated: false, requestUrl, baseUrl: getPublicOrigin(requestUrl), diff --git a/packages/owletto-backend/src/tools/admin/manage_entity.ts b/packages/owletto-backend/src/tools/admin/manage_entity.ts index 0bb39c676..ff7f2328e 100644 --- a/packages/owletto-backend/src/tools/admin/manage_entity.ts +++ b/packages/owletto-backend/src/tools/admin/manage_entity.ts @@ -39,6 +39,7 @@ import { validateSource, validateTypeRule, } from '../../utils/relationship-validation'; +import { resolveMemberSchemaFieldsFromSchema } from '../../utils/member-entity-type'; import { validateEntityMetadata } from '../../utils/schema-validation'; import { buildEntityUrl, @@ -664,11 +665,40 @@ async function handleUpdate( }; } +// Access policy for the built-in $member entity type: +// - Anyone who isn't a member of the org cannot see the member list at all. +// - Members who aren't admin/owner see names + non-PII metadata, but not the +// email address. +// - Only admin/owner see the email field. +function canSeeMemberList(ctx: ToolContext): boolean { + return !!ctx.memberRole; +} + +function canSeeMemberEmail(ctx: ToolContext): boolean { + return ctx.memberRole === 'owner' || ctx.memberRole === 'admin'; +} + +function redactMemberEmail( + metadata: Record, + schema: Record | null | undefined +): Record { + const { emailField } = resolveMemberSchemaFieldsFromSchema(schema); + if (!(emailField in metadata)) return metadata; + const { [emailField]: _removed, ...rest } = metadata; + return rest; +} + async function handleList( args: ManageEntityArgs, env: Env, ctx: ToolContext ): Promise { + if (args.entity_type === '$member' && !canSeeMemberList(ctx)) { + throw new Error( + 'The member list is only visible to members of this workspace. Join the workspace to see members.' + ); + } + const sql = getDb(); // Run list query and entity type schema fetch in parallel @@ -709,6 +739,7 @@ async function handleList( const baseUrl = getPublicWebUrl(ctx.requestUrl, ctx.baseUrl); const ownerSlug = await getOrganizationSlug(ctx.organizationId); + const hideMemberEmail = !canSeeMemberEmail(ctx); return { action: 'list', @@ -722,6 +753,11 @@ async function handleList( parentSlug: e.parent_slug ?? null, } : null; + const rawMetadata = e.metadata ?? {}; + const metadata = + hideMemberEmail && e.entity_type === '$member' + ? redactMemberEmail(rawMetadata, schema) + : rawMetadata; return { id: e.id, entity_type: e.entity_type, @@ -731,7 +767,7 @@ async function handleList( parent_name: e.parent_name, parent_slug: e.parent_slug, parent_entity_type: e.parent_entity_type, - metadata: e.metadata ?? {}, + metadata, enabled_classifiers: e.enabled_classifiers, created_at: e.created_at, total_content: e.total_content, @@ -766,8 +802,26 @@ async function handleGet( throw new Error(`Entity with ID ${entityId} not found`); } + if (entity.entity_type === '$member' && !canSeeMemberList(ctx)) { + throw new Error( + 'Member details are only visible to members of this workspace. Join the workspace to see members.' + ); + } + const viewUrl = await buildEntityViewUrl(ctx, entity); + let metadata = entity.metadata ?? {}; + if (entity.entity_type === '$member' && !canSeeMemberEmail(ctx)) { + const sql = getDb(); + const rows = await sql` + SELECT metadata_schema FROM entity_types + WHERE slug = '$member' AND organization_id = ${ctx.organizationId} AND deleted_at IS NULL + LIMIT 1 + `; + const memberSchema = (rows[0]?.metadata_schema as Record | null) ?? null; + metadata = redactMemberEmail(metadata, memberSchema); + } + return { action: 'get', entity: { @@ -778,7 +832,7 @@ async function handleGet( parent_id: entity.parent_id, parent_name: entity.parent_name, parent_slug: entity.parent_slug ?? null, - metadata: entity.metadata ?? {}, + metadata, enabled_classifiers: entity.enabled_classifiers, created_at: toIsoStringOrNow(entity.created_at), view_url: viewUrl, diff --git a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts index e3ac44cde..3d4ebdb02 100644 --- a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts +++ b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts @@ -12,6 +12,7 @@ import { type Static, Type } from '@sinclair/typebox'; import { type DbClient, getDb } from '../../db/client'; import type { Env } from '../../index'; import logger from '../../utils/logger'; +import { ensureMemberEntityType } from '../../utils/member-entity-type'; import { RESERVED_ENTITY_TYPES } from '../../utils/reserved'; import { resolveUsernames } from '../../utils/resolve-usernames'; import type { ToolContext } from '../registry'; @@ -369,14 +370,22 @@ async function etHandleGet( if (!slug) throw new Error('slug is required for get action'); const sql = getDb(); - const rows = await sql.unsafe( - `SELECT ${ENTITY_TYPE_COLUMNS} FROM entity_types - WHERE slug = $1 - AND deleted_at IS NULL - AND organization_id = $2 - LIMIT 1`, - [slug, ctx.organizationId] - ); + const fetchRow = () => + sql.unsafe( + `SELECT ${ENTITY_TYPE_COLUMNS} FROM entity_types + WHERE slug = $1 + AND deleted_at IS NULL + AND organization_id = $2 + LIMIT 1`, + [slug, ctx.organizationId] + ); + + let rows = await fetchRow(); + + if (rows.length === 0 && slug === '$member') { + await ensureMemberEntityType(ctx.organizationId); + rows = await fetchRow(); + } if (rows.length === 0) { return { schema_type: 'entity_type', action: 'get', entity_type: null }; diff --git a/packages/owletto-backend/src/tools/execute.ts b/packages/owletto-backend/src/tools/execute.ts index 9c973c9af..25c5639ad 100644 --- a/packages/owletto-backend/src/tools/execute.ts +++ b/packages/owletto-backend/src/tools/execute.ts @@ -201,6 +201,7 @@ export function toToolContext(authCtx: AuthContext): ToolContext { return { organizationId: authCtx.organizationId, userId: authCtx.userId, + memberRole: authCtx.memberRole, agentId: authCtx.agentId, isAuthenticated: authCtx.isAuthenticated, clientId: authCtx.clientId, diff --git a/packages/owletto-backend/src/tools/registry.ts b/packages/owletto-backend/src/tools/registry.ts index bb6883b50..055654625 100644 --- a/packages/owletto-backend/src/tools/registry.ts +++ b/packages/owletto-backend/src/tools/registry.ts @@ -50,6 +50,8 @@ export interface ToolContext { organizationId: string; /** User ID from OAuth token, PAT, or session (null for anonymous public reads) */ userId: string | null; + /** Caller's role in the organization (null for non-members reading a public workspace). */ + memberRole: string | null; /** Durable Owletto/Lobu agent identity bound to this MCP session, when provided. */ agentId?: string | null; /** Whether request was authenticated */ diff --git a/packages/owletto-backend/src/tools/resolve_path.ts b/packages/owletto-backend/src/tools/resolve_path.ts index 73b29be3a..ff69e0152 100644 --- a/packages/owletto-backend/src/tools/resolve_path.ts +++ b/packages/owletto-backend/src/tools/resolve_path.ts @@ -17,6 +17,7 @@ import { type DataSourceInput, executeDataSources, } from '../utils/execute-data-sources'; +import { resolveMemberSchemaFieldsFromSchema } from '../utils/member-entity-type'; import { RESERVED_PATHS } from '../utils/reserved'; import { getWorkspaceProvider } from '../workspace'; import type { ToolContext } from './registry'; @@ -456,13 +457,34 @@ async function _resolvePath( ); const mergedTabs = mergeTabs(entityTabs, entityTypeTabs); const processedEntityTabs = await processTabsDataSources(mergedTabs, entityDataCtx, sql); + if (entityRow.entity_type === '$member' && !ctx.memberRole) { + throw new Error( + 'Member details are only visible to members of this workspace. Join the workspace to see members.' + ); + } + const rawEntityMetadata = entityRow.metadata ?? {}; + let safeEntityMetadata = rawEntityMetadata; + const canSeeEmail = ctx.memberRole === 'owner' || ctx.memberRole === 'admin'; + if (entityRow.entity_type === '$member' && !canSeeEmail) { + const schemaRow = await simpleQuery(sql` + SELECT metadata_schema FROM entity_types + WHERE slug = '$member' AND organization_id = ${workspace.id} AND deleted_at IS NULL + LIMIT 1 + `); + const memberSchema = (schemaRow[0]?.metadata_schema as Record | null) ?? null; + const { emailField } = resolveMemberSchemaFieldsFromSchema(memberSchema); + if (emailField in safeEntityMetadata) { + const { [emailField]: _drop, ...rest } = safeEntityMetadata; + safeEntityMetadata = rest; + } + } resolvedEntity = { id: entityRow.id, entity_type: entityRow.entity_type, slug: entityRow.slug, name: entityRow.name, parent_id: entityRow.parent_id, - metadata: entityRow.metadata ?? {}, + metadata: safeEntityMetadata, json_template: entityCleanTpl, json_template_version: toVersionNumber(entityRow.json_template_version), template_data: entityTemplateData, diff --git a/packages/owletto-web b/packages/owletto-web index d5281e362..8510d7cca 160000 --- a/packages/owletto-web +++ b/packages/owletto-web @@ -1 +1 @@ -Subproject commit d5281e362dc143688b27409f3704213e92fa51af +Subproject commit 8510d7ccab50972fcd534344dc2d5c533db71114