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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/mesh/migrations/seeds/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ export async function seed(db: Kysely<Database>): Promise<BenchmarkSeedResult> {
.values({
id: userId,
email: "[email protected]",
emailVerified: 1,
name: "Benchmark User",
image: null,
role: "admin",
banned: null,
banReason: null,
banExpires: null,
createdAt: now,
updatedAt: now,
})
Expand Down
4 changes: 3 additions & 1 deletion apps/mesh/src/core/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { verifyMeshToken } from "../auth/jwt";
import { CredentialVault } from "../encryption/credential-vault";
import { ConnectionStorage } from "../storage/connection";
import { GatewayStorage } from "../storage/gateway";
import { SqlMonitoringStorage } from "../storage/monitoring";
import { OrganizationSettingsStorage } from "../storage/organization-settings";
import { UserStorage } from "../storage/user";
import type { Database, Permission } from "../storage/types";
import { AccessControl } from "./access-control";
import type {
Expand Down Expand Up @@ -339,7 +341,6 @@ function createBoundAuthClient(ctx: AuthContext): BoundAuthClient {
}

// Import built-in roles from separate module to avoid circular dependency
import { SqlMonitoringStorage } from "@/storage/monitoring";
import { BUILTIN_ROLES } from "../auth/roles";
import { WellKnownMCPId } from "./well-known-mcp";
import { ConnectionEntity } from "@/tools/connection/schema";
Expand Down Expand Up @@ -622,6 +623,7 @@ export function createMeshContextFactory(
organizationSettings: new OrganizationSettingsStorage(config.db),
monitoring: new SqlMonitoringStorage(config.db),
gateways: new GatewayStorage(config.db),
users: new UserStorage(config.db),
// Note: Organizations, teams, members, roles managed by Better Auth organization plugin
// Note: Policies handled by Better Auth permissions directly
// Note: API keys (tokens) managed by Better Auth API Key plugin
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/core/define-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const createMockContext = (): MeshContext => ({
}),
} as never,
gateways: null as never,
users: null as never,
},
vault: null as never,
authInstance: null as never,
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/core/mesh-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const createMockContext = (overrides?: Partial<MeshContext>): MeshContext => ({
organizationSettings: null as never,
monitoring: null as never,
gateways: null as never,
users: null as never,
},
vault: null as never,
authInstance: null as never,
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/core/mesh-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ import type { ConnectionStorage } from "../storage/connection";
import type { GatewayStorage } from "../storage/gateway";
import type { SqlMonitoringStorage } from "../storage/monitoring";
import type { OrganizationSettingsStorage } from "../storage/organization-settings";
import type { UserStorage } from "../storage/user";

// Better Auth instance type - flexible for testing
// In production, this is the actual Better Auth instance
Expand All @@ -243,6 +244,7 @@ export interface MeshStorage {
organizationSettings: OrganizationSettingsStorage;
monitoring: SqlMonitoringStorage;
gateways: GatewayStorage;
users: UserStorage;
}

// ============================================================================
Expand Down
25 changes: 25 additions & 0 deletions apps/mesh/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ export interface UserTable {
updatedAt: ColumnType<Date, Date | string, Date | string>;
}

/**
* Better Auth core user table definition (singular: "user")
* Includes avatar image and other auth-related fields.
*/
export interface BetterAuthUserTable {
id: string;
email: string;
emailVerified: number;
name: string;
image: string | null;
role: string | null;
banned: number | null;
banReason: string | null;
banExpires: string | null;
createdAt: ColumnType<Date, string, string>;
updatedAt: ColumnType<Date, string, string>;
}
// ============================================================================
// Runtime Entity Types (for application code)
// ============================================================================
Expand All @@ -81,6 +98,13 @@ export interface User {
updatedAt: Date | string;
}

/**
* User entity with image - Extended representation including Better Auth avatar
*/
export interface UserWithImage extends User {
image?: string;
}

/**
* Organization entity - Runtime representation (from Better Auth)
* Better Auth organization plugin provides this data
Expand Down Expand Up @@ -633,6 +657,7 @@ export interface GatewayWithConnections extends Gateway {
export interface Database {
// Core tables (all within organization scope)
users: UserTable; // System users
user: BetterAuthUserTable; // Better Auth core table (singular)
connections: MCPConnectionTable; // MCP connections (organization-scoped)
organization_settings: OrganizationSettingsTable; // Organization-level configuration
api_keys: ApiKeyTable; // Better Auth API keys
Expand Down
77 changes: 77 additions & 0 deletions apps/mesh/src/storage/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* User Storage
*
* Provides access to Better Auth user data with organization-scoped access control.
* Users can only fetch data for users in their shared organizations.
*/

import type { Kysely } from "kysely";
import type { Database, UserWithImage } from "./types";

/**
* User storage interface
*/
export interface UserStoragePort {
findById(
userId: string,
requestingUserId: string,
): Promise<UserWithImage | null>;
}

/**
* User storage implementation using Kysely
*/
export class UserStorage implements UserStoragePort {
constructor(private db: Kysely<Database>) {}

/**
* Find a user by ID, ensuring the requesting user shares at least one organization
*
* @param userId - The user ID to fetch
* @param requestingUserId - The user making the request (for authorization)
* @returns User data or null if not found/unauthorized
*/
async findById(
userId: string,
requestingUserId: string,
): Promise<UserWithImage | null> {
// Query the user table, but only if the requesting user shares an organization
// with the target user (via the member table)
const result = await this.db
.selectFrom("user")
.select([
"user.id",
"user.name",
"user.email",
"user.image",
"user.createdAt",
"user.updatedAt",
])
.where("user.id", "=", userId)
.where((eb) =>
eb.exists(
eb
.selectFrom("member as m1")
.innerJoin("member as m2", "m1.organizationId", "m2.organizationId")
.select("m1.id")
.where("m1.userId", "=", userId)
.where("m2.userId", "=", requestingUserId),
),
)
.executeTakeFirst();

if (!result) {
return null;
}

return {
id: result.id,
name: result.name,
email: result.email,
role: "", // Not exposed in this context
createdAt: result.createdAt,
updatedAt: result.updatedAt,
image: result.image ?? undefined,
};
}
}
1 change: 1 addition & 0 deletions apps/mesh/src/tools/connection/connection-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe("Connection Tools", () => {
} as never,
monitoring: null as never,
gateways: null as never,
users: null as never,
},
vault: null as never,
authInstance: null as never,
Expand Down
4 changes: 4 additions & 0 deletions apps/mesh/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as EventBusTools from "./eventbus";
import * as GatewayTools from "./gateway";
import * as MonitoringTools from "./monitoring";
import * as OrganizationTools from "./organization";
import * as UserTools from "./user";
import { ToolName } from "./registry";

// All available tools - types are inferred
Expand Down Expand Up @@ -65,6 +66,9 @@ export const ALL_TOOLS = [
EventBusTools.EVENT_ACK,
EventBusTools.EVENT_SUBSCRIPTION_LIST,
EventBusTools.EVENT_SYNC_SUBSCRIPTIONS,

// User tools
UserTools.USER_GET,
] as const satisfies { name: ToolName }[];

export type MCPMeshTools = typeof ALL_TOOLS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const createMockContext = (
} as never,
monitoring: null as never,
gateways: null as never,
users: null as never,
},
vault: null as never,
authInstance: authInstance as unknown as BetterAuthInstance,
Expand Down
12 changes: 12 additions & 0 deletions apps/mesh/src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type ToolCategory =
| "Connections"
| "Gateways"
| "Monitoring"
| "Users"
| "API Keys"
| "Event Bus";

Expand Down Expand Up @@ -73,6 +74,8 @@ const ALL_TOOL_NAMES = [
"EVENT_ACK",
"EVENT_SUBSCRIPTION_LIST",
"EVENT_SYNC_SUBSCRIPTIONS",
// User tools
"USER_GET",
] as const;

/**
Expand Down Expand Up @@ -305,6 +308,12 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [
description: "Sync subscriptions to desired state",
category: "Event Bus",
},
// User tools
{
name: "USER_GET",
description: "Get a user by id",
category: "Users",
},
];

/**
Expand Down Expand Up @@ -347,6 +356,8 @@ const TOOL_LABELS: Record<ToolName, string> = {
EVENT_ACK: "Acknowledge event delivery",
EVENT_SUBSCRIPTION_LIST: "List event subscriptions",
EVENT_SYNC_SUBSCRIPTIONS: "Sync subscriptions to desired state",

USER_GET: "Get user by id",
};

// ============================================================================
Expand All @@ -362,6 +373,7 @@ export function getToolsByCategory() {
Connections: [],
Gateways: [],
Monitoring: [],
Users: [],
"API Keys": [],
"Event Bus": [],
};
Expand Down
55 changes: 55 additions & 0 deletions apps/mesh/src/tools/user/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* USER_GET Tool
*
* Fetch a user's public profile (name/email/avatar) by user id.
* Access is restricted to users in shared organizations.
*/

import { z } from "zod";
import { defineTool } from "../../core/define-tool";
import { getUserId, requireAuth } from "../../core/mesh-context";

const InputSchema = z.object({
id: z.string().min(1),
});

const OutputSchema = z.object({
user: z
.object({
id: z.string(),
name: z.string(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
});

export const USER_GET = defineTool({
name: "USER_GET",
description: "Get a user by id (restricted to shared organizations)",
inputSchema: InputSchema,
outputSchema: OutputSchema,
handler: async (input, ctx) => {
await ctx.access.check();
requireAuth(ctx);

const requesterUserId = getUserId(ctx);
if (!requesterUserId) {
throw new Error("Authentication required");
}

const user = await ctx.storage.users.findById(input.id, requesterUserId);
if (!user) {
return { user: null };
}

return {
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image ?? null,
},
};
},
});
1 change: 1 addition & 0 deletions apps/mesh/src/tools/user/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { USER_GET } from "./get";
16 changes: 16 additions & 0 deletions apps/mesh/src/web/components/collections/collections-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@untitledui/icons";
import type { JsonSchema } from "@/web/utils/constants";
import { IntegrationIcon } from "../integration-icon.tsx";
import { User } from "../user/user.tsx";

// Field names that should be rendered as icon columns
const ICON_FIELD_NAMES = ["icon", "avatar", "logo"];
Expand Down Expand Up @@ -373,6 +374,21 @@ function generateColumnsFromSchema<T extends BaseCollectionEntity>(
};
}

// Handle user fields (created_by, updated_by)
if (key === "created_by" || key === "updated_by") {
return {
id: key,
header: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
render: (row) => {
const val = row[key as keyof T];
if (!val) return "—";
return <User id={String(val)} size="xs" />;
},
sortable: isSortable,
cellClassName: "max-w-[250px]",
};
}

// Handle date fields
if (fieldSchema?.format === "date-time" || key.endsWith("_at")) {
return {
Expand Down
Loading