diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1cd253a1c..59149f59d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,14 +15,19 @@
 ## 13.x.x (unreleased)
 
 ### General
-- カスタム絵文字関連の変更
+- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加
+	- Deckのカラムとしても追加可能
+- カスタム絵文字関連の改善
   * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように
   * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように
+- カスタム絵文字でリアクションできないことがある問題を修正
 
 ### Client
 -
 
 ### Server
+- Misskey Webでのサーバーサイドエラー画面を改善
+- Misskey Webでのサーバーサイドエラーのログが残るように
 - ノート作成時のアンテナ追加パフォーマンスを改善
 - フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io
 
diff --git a/gulpfile.js b/gulpfile.js
index a04ab4c1ad..6507aad60e 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => {
 });
 
 gulp.task('build:backend:style', () => {
-	return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css'])
+	return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css'])
 		.pipe(cssnano({
 			zindex: false
 		}))
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index f96f88fd4b..474cafca47 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1946,6 +1946,7 @@ _deck:
     channel: "チャンネル"
     mentions: "あなた宛て"
     direct: "ダイレクト"
+    roleTimeline: "ロールタイムライン"
 
 _dialog:
   charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 416c3de5a8..eb18fb1b73 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
 import { UtilityService } from '@/core/UtilityService.js';
 import type { Config } from '@/config.js';
 import { query } from '@/misc/prelude/url.js';
+import type { Serialized } from '@/server/api/stream/types.js';
 
 @Injectable()
 export class CustomEmojiService {
@@ -44,7 +45,13 @@ export class CustomEmojiService {
 			memoryCacheLifetime: 1000 * 60 * 3, // 3m
 			fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
 			toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
-			fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
+			fromRedisConverter: (value) => {
+				if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
+				return new Map(JSON.parse(value).map((x: Serialized<Emoji>) => [x.name, {
+					...x,
+					updatedAt: x.updatedAt && new Date(x.updatedAt),
+				}]));
+			},
 		});
 	}
 
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 9f4de5f985..2c2687a90c 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -14,11 +14,13 @@ import type {
 	MainStreamTypes,
 	NoteStreamTypes,
 	UserListStreamTypes,
+	RoleTimelineStreamTypes,
 } from '@/server/api/stream/types.js';
 import type { Packed } from '@/misc/json-schema.js';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
 import { bindThis } from '@/decorators.js';
+import { Role } from '@/models';
 
 @Injectable()
 export class GlobalEventService {
@@ -81,6 +83,11 @@ export class GlobalEventService {
 		this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	@bindThis
+	public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
+		this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	@bindThis
 	public publishNotesStream(note: Packed<'Note'>): void {
 		this.publish('notesStream', null, note);
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 32e4fe7f8a..79629cb2a8 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -547,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
 
 			this.globalEventService.publishNotesStream(noteObj);
 
+			this.roleService.addNoteToRoleTimeline(noteObj);
+
 			this.webhookService.getActiveWebhooks().then(webhooks => {
 				webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
 				for (const webhook of webhooks) {
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 77645e3f06..2a4271aa98 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { StreamMessages } from '@/server/api/stream/types.js';
 import { IdService } from '@/core/IdService.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
+import type { Packed } from '@/misc/json-schema';
 import type { OnApplicationShutdown } from '@nestjs/common';
 
 export type RolePolicies = {
@@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
 	public static NotAssignedError = class extends Error {};
 
 	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
 		@Inject(DI.redisForSub)
 		private redisForSub: Redis.Redis,
 
@@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown {
 		this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
 	}
 
+	@bindThis
+	public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
+		const roles = await this.getUserRoles(note.userId);
+
+		const redisPipeline = this.redisClient.pipeline();
+
+		for (const role of roles) {
+			redisPipeline.xadd(
+				`roleTimeline:${role.id}`,
+				'MAXLEN', '~', '1000',
+				'*',
+				'note', note.id);
+
+			this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
+		}
+
+		redisPipeline.exec();
+	}
+
 	@bindThis
 	public onApplicationShutdown(signal?: string | undefined) {
 		this.redisForSub.off('message', this.onMessage);
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index dacaa7263a..a8eded6733 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
 import { UserBlockingService } from '@/core/UserBlockingService.js';
 import { MetaService } from '@/core/MetaService.js';
 import { CacheService } from '@/core/CacheService.js';
+import type { Config } from '@/config.js';
 import Logger from '../logger.js';
 
 const logger = new Logger('following/create');
@@ -44,6 +45,9 @@ export class UserFollowingService implements OnModuleInit {
 	constructor(
 		private moduleRef: ModuleRef,
 
+		@Inject(DI.config)
+		private config: Config,
+
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
 
@@ -411,7 +415,7 @@ export class UserFollowingService implements OnModuleInit {
 		}
 
 		if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
-			const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee));
+			const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
 			this.queueService.deliver(follower, content, followee.inbox, false);
 		}
 	}
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index d35414acf7..f413246a1f 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -8,7 +8,7 @@ export class RedisKVCache<T> {
 	private memoryCache: MemoryKVCache<T>;
 	private fetcher: (key: string) => Promise<T>;
 	private toRedisConverter: (value: T) => string;
-	private fromRedisConverter: (value: string) => T;
+	private fromRedisConverter: (value: string) => T | undefined;
 
 	constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
 		lifetime: RedisKVCache<T>['lifetime'];
@@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
 	private memoryCache: MemorySingleCache<T>;
 	private fetcher: () => Promise<T>;
 	private toRedisConverter: (value: T) => string;
-	private fromRedisConverter: (value: string) => T;
+	private fromRedisConverter: (value: string) => T | undefined;
 
 	constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
 		lifetime: RedisSingleCache<T>['lifetime'];
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index 5799622074..e13e9265ab 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
 import accepts from 'accepts';
 import vary from 'vary';
 import { DI } from '@/di-symbols.js';
-import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
+import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js';
 import * as url from '@/misc/prelude/url.js';
 import type { Config } from '@/config.js';
 import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -54,6 +54,9 @@ export class ActivityPubServerService {
 		@Inject(DI.followingsRepository)
 		private followingsRepository: FollowingsRepository,
 
+		@Inject(DI.followRequestsRepository)
+		private followRequestsRepository: FollowRequestsRepository,
+
 		private utilityService: UtilityService,
 		private userEntityService: UserEntityService,
 		private apRendererService: ApRendererService,
@@ -205,22 +208,22 @@ export class ActivityPubServerService {
 			reply.code(400);
 			return;
 		}
-	
+
 		const page = request.query.page === 'true';
-	
+
 		const user = await this.usersRepository.findOneBy({
 			id: userId,
 			host: IsNull(),
 		});
-	
+
 		if (user == null) {
 			reply.code(404);
 			return;
 		}
-	
+
 		//#region Check ff visibility
 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
-	
+
 		if (profile.ffVisibility === 'private') {
 			reply.code(403);
 			reply.header('Cache-Control', 'public, max-age=30');
@@ -231,31 +234,31 @@ export class ActivityPubServerService {
 			return;
 		}
 		//#endregion
-	
+
 		const limit = 10;
 		const partOf = `${this.config.url}/users/${userId}/following`;
-	
+
 		if (page) {
 			const query = {
 				followerId: user.id,
 			} as FindOptionsWhere<Following>;
-	
+
 			// カーソルが指定されている場合
 			if (cursor) {
 				query.id = LessThan(cursor);
 			}
-	
+
 			// Get followings
 			const followings = await this.followingsRepository.find({
 				where: query,
 				take: limit + 1,
 				order: { id: -1 },
 			});
-	
+
 			// 「次のページ」があるかどうか
 			const inStock = followings.length === limit + 1;
 			if (inStock) followings.pop();
-	
+
 			const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId)));
 			const rendered = this.apRendererService.renderOrderedCollectionPage(
 				`${partOf}?${url.query({
@@ -269,7 +272,7 @@ export class ActivityPubServerService {
 					cursor: followings[followings.length - 1].id,
 				})}` : undefined,
 			);
-	
+
 			this.setResponseType(request, reply);
 			return (this.apRendererService.addContext(rendered));
 		} else {
@@ -330,33 +333,33 @@ export class ActivityPubServerService {
 			reply.code(400);
 			return;
 		}
-	
+
 		const untilId = request.query.until_id;
 		if (untilId != null && typeof untilId !== 'string') {
 			reply.code(400);
 			return;
 		}
-	
+
 		const page = request.query.page === 'true';
-	
+
 		if (countIf(x => x != null, [sinceId, untilId]) > 1) {
 			reply.code(400);
 			return;
 		}
-	
+
 		const user = await this.usersRepository.findOneBy({
 			id: userId,
 			host: IsNull(),
 		});
-	
+
 		if (user == null) {
 			reply.code(404);
 			return;
 		}
-	
+
 		const limit = 20;
 		const partOf = `${this.config.url}/users/${userId}/outbox`;
-	
+
 		if (page) {
 			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
 				.andWhere('note.userId = :userId', { userId: user.id })
@@ -365,11 +368,11 @@ export class ActivityPubServerService {
 					.orWhere('note.visibility = \'home\'');
 				}))
 				.andWhere('note.localOnly = FALSE');
-	
+
 			const notes = await query.take(limit).getMany();
-	
+
 			if (sinceId) notes.reverse();
-	
+
 			const activities = await Promise.all(notes.map(note => this.packActivity(note)));
 			const rendered = this.apRendererService.renderOrderedCollectionPage(
 				`${partOf}?${url.query({
@@ -387,7 +390,7 @@ export class ActivityPubServerService {
 					until_id: notes[notes.length - 1].id,
 				})}` : undefined,
 			);
-	
+
 			this.setResponseType(request, reply);
 			return (this.apRendererService.addContext(rendered));
 		} else {
@@ -457,7 +460,7 @@ export class ActivityPubServerService {
 		// note
 		fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
 			vary(reply.raw, 'Accept');
-	
+
 			const note = await this.notesRepository.findOneBy({
 				id: request.params.note,
 				visibility: In(['public', 'home']),
@@ -639,6 +642,41 @@ export class ActivityPubServerService {
 			return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
 		});
 
+		// follow
+		fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
+			// This may be used before the follow is completed, so we do not
+			// check if the following exists and only check if the follow request exists.
+
+			const followRequest = await this.followRequestsRepository.findOneBy({
+				id: request.params.followRequestId,
+			});
+
+			if (followRequest == null) {
+				reply.code(404);
+				return;
+			}
+
+			const [follower, followee] = await Promise.all([
+				this.usersRepository.findOneBy({
+					id: followRequest.followerId,
+					host: IsNull(),
+				}),
+				this.usersRepository.findOneBy({
+					id: followRequest.followeeId,
+					host: Not(IsNull()),
+				}),
+			]);
+
+			if (follower == null || followee == null) {
+				reply.code(404);
+				return;
+			}
+
+			reply.header('Cache-Control', 'public, max-age=180');
+			this.setResponseType(request, reply);
+			return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
+		});
+
 		done();
 	}
 }
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 6bae0bafda..da86b2c1d3 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
 import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
 import { UserListChannelService } from './api/stream/channels/user-list.js';
 import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+import { ClientLoggerService } from './web/ClientLoggerService.js';
+import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
 
 @Module({
 	imports: [
@@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
 	],
 	providers: [
 		ClientServerService,
+		ClientLoggerService,
 		FeedService,
 		UrlPreviewService,
 		ActivityPubServerService,
@@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
 		DriveChannelService,
 		GlobalTimelineChannelService,
 		HashtagChannelService,
+		RoleTimelineChannelService,
 		HomeTimelineChannelService,
 		HybridTimelineChannelService,
 		LocalTimelineChannelService,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index ca89d82853..689f90287e 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
 import * as ep___roles_list from './endpoints/roles/list.js';
 import * as ep___roles_show from './endpoints/roles/show.js';
 import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r
 const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
 const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
 const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
+const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default };
 const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
 const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
 const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$roles_list,
 		$roles_show,
 		$roles_users,
+		$roles_notes,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
@@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$roles_list,
 		$roles_show,
 		$roles_users,
+		$roles_notes,
 		$requestResetPassword,
 		$resetDb,
 		$resetPassword,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index dab897117d..d0fe6a57c1 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
 import * as ep___roles_list from './endpoints/roles/list.js';
 import * as ep___roles_show from './endpoints/roles/show.js';
 import * as ep___roles_users from './endpoints/roles/users.js';
+import * as ep___roles_notes from './endpoints/roles/notes.js';
 import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
 import * as ep___resetDb from './endpoints/reset-db.js';
 import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -626,6 +627,7 @@ const eps = [
 	['roles/list', ep___roles_list],
 	['roles/show', ep___roles_show],
 	['roles/users', ep___roles_users],
+	['roles/notes', ep___roles_notes],
 	['request-reset-password', ep___requestResetPassword],
 	['reset-db', ep___resetDb],
 	['reset-password', ep___resetPassword],
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index f08c20ae48..df83fe5f2a 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -76,11 +76,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				throw new ApiError(meta.errors.noSuchAntenna);
 			}
 
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
 			const noteIdsRes = await this.redisClient.xrevrange(
 				`antennaTimeline:${antenna.id}`,
 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
 				'-',
-				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+				'COUNT', limit);
 
 			if (noteIdsRes.length === 0) {
 				return [];
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index f27b4e86d4..ba0487f223 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 			const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
 			const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
 
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
 			const notificationsRes = await this.redisClient.xrevrange(
 				`notificationTimeline:${me.id}`,
 				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
 				'-',
-				'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
+				'COUNT', limit);
 
 			if (notificationsRes.length === 0) {
 				return [];
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
new file mode 100644
index 0000000000..d79528593f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -0,0 +1,109 @@
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { NotesRepository, RolesRepository } from '@/models/index.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['role', 'notes'],
+
+	requireCredential: true,
+
+	errors: {
+		noSuchRole: {
+			message: 'No such role.',
+			code: 'NO_SUCH_ROLE',
+			id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e',
+		},
+	},
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'Note',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roleId: { type: 'string', format: 'misskey:id' },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		sinceDate: { type: 'integer' },
+		untilDate: { type: 'integer' },
+	},
+	required: ['roleId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		@Inject(DI.rolesRepository)
+		private rolesRepository: RolesRepository,
+
+		private idService: IdService,
+		private noteEntityService: NoteEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const role = await this.rolesRepository.findOneBy({
+				id: ps.roleId,
+			});
+
+			if (role == null) {
+				throw new ApiError(meta.errors.noSuchRole);
+			}
+
+			const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
+			const noteIdsRes = await this.redisClient.xrevrange(
+				`roleTimeline:${role.id}`,
+				ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
+				'-',
+				'COUNT', limit);
+
+			if (noteIdsRes.length === 0) {
+				return [];
+			}
+
+			const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
+
+			if (noteIds.length === 0) {
+				return [];
+			}
+
+			const query = this.notesRepository.createQueryBuilder('note')
+				.where('note.id IN (:...noteIds)', { noteIds: noteIds })
+				.innerJoinAndSelect('note.user', 'user')
+				.leftJoinAndSelect('note.reply', 'reply')
+				.leftJoinAndSelect('note.renote', 'renote')
+				.leftJoinAndSelect('reply.user', 'replyUser')
+				.leftJoinAndSelect('renote.user', 'renoteUser');
+
+			this.queryService.generateVisibilityQuery(query, me);
+			this.queryService.generateMutedUserQuery(query, me);
+			this.queryService.generateBlockedUserQuery(query, me);
+
+			const notes = await query.getMany();
+			notes.sort((a, b) => a.id > b.id ? -1 : 1);
+
+			return await this.noteEntityService.packMany(notes, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index f9ef8218c1..c77ba66028 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
 import { AntennaChannelService } from './channels/antenna.js';
 import { DriveChannelService } from './channels/drive.js';
 import { HashtagChannelService } from './channels/hashtag.js';
+import { RoleTimelineChannelService } from './channels/role-timeline.js';
 
 @Injectable()
 export class ChannelsService {
@@ -24,6 +25,7 @@ export class ChannelsService {
 		private globalTimelineChannelService: GlobalTimelineChannelService,
 		private userListChannelService: UserListChannelService,
 		private hashtagChannelService: HashtagChannelService,
+		private roleTimelineChannelService: RoleTimelineChannelService,
 		private antennaChannelService: AntennaChannelService,
 		private channelChannelService: ChannelChannelService,
 		private driveChannelService: DriveChannelService,
@@ -43,6 +45,7 @@ export class ChannelsService {
 			case 'globalTimeline': return this.globalTimelineChannelService;
 			case 'userList': return this.userListChannelService;
 			case 'hashtag': return this.hashtagChannelService;
+			case 'roleTimeline': return this.roleTimelineChannelService;
 			case 'antenna': return this.antennaChannelService;
 			case 'channel': return this.channelChannelService;
 			case 'drive': return this.driveChannelService;
diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts
new file mode 100644
index 0000000000..9d106c8b2f
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts
@@ -0,0 +1,75 @@
+import { Injectable } from '@nestjs/common';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { bindThis } from '@/decorators.js';
+import Channel from '../channel.js';
+import { StreamMessages } from '../types.js';
+
+class RoleTimelineChannel extends Channel {
+	public readonly chName = 'roleTimeline';
+	public static shouldShare = false;
+	public static requireCredential = false;
+	private roleId: string;
+
+	constructor(
+		private noteEntityService: NoteEntityService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+		//this.onNote = this.onNote.bind(this);
+	}
+
+	@bindThis
+	public async init(params: any) {
+		this.roleId = params.roleId as string;
+
+		this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent);
+	}
+
+	@bindThis
+	private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
+		if (data.type === 'note') {
+			const note = data.body;
+
+			// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
+			if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
+			// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
+			if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
+
+			if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
+
+			this.send('note', note);
+		} else {
+			this.send(data.type, data.body);
+		}
+	}
+
+	@bindThis
+	public dispose() {
+		// Unsubscribe events
+		this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
+	}
+}
+
+@Injectable()
+export class RoleTimelineChannelService {
+	public readonly shouldShare = RoleTimelineChannel.shouldShare;
+	public readonly requireCredential = RoleTimelineChannel.requireCredential;
+
+	constructor(
+		private noteEntityService: NoteEntityService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
+		return new RoleTimelineChannel(
+			this.noteEntityService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts
index ed73897e73..d9dba682cd 100644
--- a/packages/backend/src/server/api/stream/types.ts
+++ b/packages/backend/src/server/api/stream/types.ts
@@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
 	note: Note;
 }
 
+export interface RoleTimelineStreamTypes {
+	note: Packed<'Note'>;
+}
+
 export interface AdminStreamTypes {
 	newAbuseUserReport: {
 		id: AbuseUserReport['id'];
@@ -168,7 +172,7 @@ type EventUnionFromDictionary<
 > = U[keyof U];
 
 // redis通すとDateのインスタンスはstringに変換されるので
-type Serialized<T> = {
+export type Serialized<T> = {
 	[K in keyof T]:
 		T[K] extends Date
 			? string
@@ -209,6 +213,10 @@ export type StreamMessages = {
 		name: `userListStream:${UserList['id']}`;
 		payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
 	};
+	roleTimeline: {
+		name: `roleTimelineStream:${Role['id']}`;
+		payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
+	};
 	antenna: {
 		name: `antennaStream:${Antenna['id']}`;
 		payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts
new file mode 100644
index 0000000000..6a882aa766
--- /dev/null
+++ b/packages/backend/src/server/web/ClientLoggerService.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import type Logger from '@/logger.js';
+import { LoggerService } from '@/core/LoggerService.js';
+
+@Injectable()
+export class ClientLoggerService {
+	public logger: Logger;
+
+	constructor(
+		private loggerService: LoggerService,
+	) {
+		this.logger = this.loggerService.getLogger('client');
+	}
+}
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 99ae1b7af6..50b23a0682 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -1,6 +1,7 @@
 import { dirname } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { Inject, Injectable } from '@nestjs/common';
+import { v4 as uuid } from 'uuid';
 import { createBullBoard } from '@bull-board/api';
 import { BullAdapter } from '@bull-board/api/bullAdapter.js';
 import { FastifyAdapter } from '@bull-board/fastify';
@@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi
 import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
 import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
 import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type Logger from '@/logger.js';
 import { deepClone } from '@/misc/clone.js';
 import { bindThis } from '@/decorators.js';
 import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
@@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' };
 import { FeedService } from './FeedService.js';
 import { UrlPreviewService } from './UrlPreviewService.js';
 import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
+import { ClientLoggerService } from './ClientLoggerService.js';
 
 const _filename = fileURLToPath(import.meta.url);
 const _dirname = dirname(_filename);
@@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`;
 
 @Injectable()
 export class ClientServerService {
+	private logger: Logger;
+
 	constructor(
 		@Inject(DI.config)
 		private config: Config,
@@ -85,6 +90,7 @@ export class ClientServerService {
 		private urlPreviewService: UrlPreviewService,
 		private feedService: FeedService,
 		private roleService: RoleService,
+		private clientLoggerService: ClientLoggerService,
 
 		@Inject('queue:system') public systemQueue: SystemQueue,
 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@@ -649,6 +655,24 @@ export class ClientServerService {
 			return await renderBase(reply);
 		});
 
+		fastify.setErrorHandler(async (error, request, reply) => {
+			const errId = uuid();
+			this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, {
+				path: request.routerPath,
+				params: request.params,
+				query: request.query,
+				code: error.name,
+				stack: error.stack,
+				id: errId,
+			});
+			reply.code(500);
+			reply.header('Cache-Control', 'max-age=10, must-revalidate');
+			return await reply.view('error', {
+				code: error.code,
+				id: errId,
+			});
+		});
+
 		done();
 	}
 }
diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css
new file mode 100644
index 0000000000..ab913f7a9f
--- /dev/null
+++ b/packages/backend/src/server/web/error.css
@@ -0,0 +1,110 @@
+* {
+    font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+}
+
+#misskey_app,
+#splash {
+    display: none !important;
+}
+
+body,
+html {
+    background-color: #222;
+    color: #dfddcc;
+    justify-content: center;
+    margin: auto;
+    padding: 10px;
+    text-align: center;
+}
+
+button {
+    border-radius: 999px;
+    padding: 0px 12px 0px 12px;
+    border: none;
+    cursor: pointer;
+    margin-bottom: 12px;
+}
+
+.button-big {
+    background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
+    line-height: 50px;
+}
+
+.button-big:hover {
+    background: rgb(153, 204, 0);
+}
+
+.button-small {
+    background: #444;
+    line-height: 40px;
+}
+
+.button-small:hover {
+    background: #555;
+}
+
+.button-label-big {
+    color: #222;
+    font-weight: bold;
+    font-size: 20px;
+    padding: 12px;
+}
+
+.button-label-small {
+    color: rgb(153, 204, 0);
+    font-size: 16px;
+    padding: 12px;
+}
+
+a {
+    color: rgb(134, 179, 0);
+    text-decoration: none;
+}
+
+p,
+li {
+    font-size: 16px;
+}
+
+.dont-worry,
+#msg {
+    font-size: 18px;
+}
+
+.icon-warning {
+    color: #dec340;
+    height: 4rem;
+    padding-top: 2rem;
+}
+
+h1 {
+    font-size: 32px;
+}
+
+code {
+    display: block;
+    font-family: Fira, FiraCode, monospace;
+    background: #333;
+    padding: 0.5rem 1rem;
+    max-width: 40rem;
+    border-radius: 10px;
+    justify-content: center;
+    margin: auto;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+summary {
+    cursor: pointer;
+}
+
+summary > * {
+    display: inline;
+    white-space: pre-wrap;
+}
+
+@media screen and (max-width: 500px) {
+    details {
+        width: 50%;
+    }
+}
\ No newline at end of file
diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug
new file mode 100644
index 0000000000..b177ae4110
--- /dev/null
+++ b/packages/backend/src/server/web/views/error.pug
@@ -0,0 +1,65 @@
+doctype html
+
+//
+	-
+	  _____ _         _           
+	 |     |_|___ ___| |_ ___ _ _ 
+	 | | | | |_ -|_ -| '_| -_| | |
+	 |_|_|_|_|___|___|_,_|___|_  |
+							 |___|
+	 Thank you for using Misskey!
+	 If you are reading this message... how about joining the development?
+	 https://github.com/misskey-dev/misskey
+	 
+
+html
+
+	head
+		meta(charset='utf-8')
+		meta(name='viewport' content='width=device-width, initial-scale=1')
+		meta(name='application-name' content='Misskey')
+		meta(name='referrer' content='origin')
+
+		title
+			block title
+				= 'An error has occurred... | Misskey'
+
+		style
+			include ../error.css
+
+body
+	svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
+		path(stroke="none", d="M0 0h24v24H0z", fill="none")
+		path(d="M12 9v2m0 4v.01")
+		path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
+	
+	h1 An error has occurred!
+
+	button.button-big(onclick="location.reload();")
+		span.button-label-big Refresh
+	
+	p.dont-worry Don't worry, it's (probably) not your fault.
+
+	p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
+
+	div#errors
+		code.
+			ERROR CODE: #{code}
+			ERROR ID: #{id}
+
+	p You may also try the following options:
+
+	p Update your os and browser.
+	p Disable an adblocker.
+
+	a(href="/flush")
+		button.button-small
+			span.button-label-small Clear preferences and cache
+	br
+	a(href="/cli")
+		button.button-small
+			span.button-label-small Start the simple client
+	br
+	a(href="/bios")
+		button.button-small
+			span.button-label-small Start the repair tool
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
new file mode 100644
index 0000000000..bc3455e346
--- /dev/null
+++ b/packages/backend/test/e2e/users.ts
@@ -0,0 +1,868 @@
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { inspect } from 'node:util';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { 
+	signup, 
+	post, 
+	page,
+	role,
+	startServer, 
+	api,
+	successfulApiCall, 
+	failedApiCall,
+	uploadFile,
+} from '../utils.js';
+import type * as misskey from 'misskey-js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('ユーザー', () => {
+	// エンティティとしてのユーザーを主眼においたテストを記述する
+	// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
+
+	const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
+		return Object.entries({ ...orig })
+			.filter(([, value]) => value !== undefined)
+			.reduce((obj: Partial<T>, [key, value]) => {
+				obj[key as keyof T] = value;
+				return obj;
+			}, {});
+	};
+
+	// FIXME: 足りないキーがたくさんある
+	type UserLite = misskey.entities.UserLite & {
+		badgeRoles: any[],
+	};
+
+	type UserDetailedNotMe = UserLite & 
+	misskey.entities.UserDetailed & {
+		roles: any[],
+	};
+
+	type MeDetailed = UserDetailedNotMe & 
+		misskey.entities.MeDetailed & {
+		showTimelineReplies: boolean,
+		achievements: object[],
+		loggedInDays: number,
+		policies: object,
+	};
+	
+	type User = MeDetailed & { token: string };	
+
+	const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
+		return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
+	};
+
+	const userLite = (user: User): Partial<UserLite> => {
+		return stripUndefined({
+			id: user.id,
+			name: user.name,
+			username: user.username,
+			host: user.host,
+			avatarUrl: user.avatarUrl,
+			avatarBlurhash: user.avatarBlurhash,
+			isBot: user.isBot,
+			isCat: user.isCat,
+			instance: user.instance,
+			emojis: user.emojis,
+			onlineStatus: user.onlineStatus,
+			badgeRoles: user.badgeRoles,
+
+			// BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。
+			isAdmin: undefined,
+			isModerator: undefined,
+		});
+	};
+
+	const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => {
+		return stripUndefined({
+			...userLite(user),
+			url: user.url,
+			uri: user.uri,
+			movedToUri: user.movedToUri,
+			alsoKnownAs: user.alsoKnownAs,
+			createdAt: user.createdAt,
+			updatedAt: user.updatedAt,
+			lastFetchedAt: user.lastFetchedAt,
+			bannerUrl: user.bannerUrl,
+			bannerBlurhash: user.bannerBlurhash,
+			isLocked: user.isLocked,
+			isSilenced: user.isSilenced,
+			isSuspended: user.isSuspended,
+			description: user.description,
+			location: user.location,
+			birthday: user.birthday,
+			lang: user.lang,
+			fields: user.fields,
+			followersCount: user.followersCount,
+			followingCount: user.followingCount,
+			notesCount: user.notesCount,
+			pinnedNoteIds: user.pinnedNoteIds,
+			pinnedNotes: user.pinnedNotes,
+			pinnedPageId: user.pinnedPageId,
+			pinnedPage: user.pinnedPage,
+			publicReactions: user.publicReactions,
+			ffVisibility: user.ffVisibility,
+			twoFactorEnabled: user.twoFactorEnabled,
+			usePasswordLessLogin: user.usePasswordLessLogin,
+			securityKeys: user.securityKeys,
+			roles: user.roles,
+		});
+	};
+
+	const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => {
+		return stripUndefined({
+			...userDetailedNotMe(user),
+			isFollowing: user.isFollowing ?? false,
+			isFollowed: user.isFollowed ?? false,
+			hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false,
+			hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false,
+			isBlocking: user.isBlocking ?? false,
+			isBlocked: user.isBlocked ?? false,
+			isMuted: user.isMuted ?? false,
+			isRenoteMuted: user.isRenoteMuted ?? false,
+		});
+	};
+
+	const meDetailed = (user: User, security = false): Partial<MeDetailed> => {
+		return stripUndefined({
+			...userDetailedNotMe(user),
+			avatarId: user.avatarId,
+			bannerId: user.bannerId,
+			isModerator: user.isModerator,
+			isAdmin: user.isAdmin,
+			injectFeaturedNote: user.injectFeaturedNote,
+			receiveAnnouncementEmail: user.receiveAnnouncementEmail,
+			alwaysMarkNsfw: user.alwaysMarkNsfw,
+			autoSensitive: user.autoSensitive,
+			carefulBot: user.carefulBot,
+			autoAcceptFollowed: user.autoAcceptFollowed,
+			noCrawle: user.noCrawle,
+			isExplorable: user.isExplorable,
+			isDeleted: user.isDeleted,
+			hideOnlineStatus: user.hideOnlineStatus,
+			hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
+			hasUnreadMentions: user.hasUnreadMentions,
+			hasUnreadAnnouncement: user.hasUnreadAnnouncement,
+			hasUnreadAntenna: user.hasUnreadAntenna,
+			hasUnreadChannel: user.hasUnreadChannel,
+			hasUnreadNotification: user.hasUnreadNotification,
+			hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
+			mutedWords: user.mutedWords,
+			mutedInstances: user.mutedInstances,
+			mutingNotificationTypes: user.mutingNotificationTypes,
+			emailNotificationTypes: user.emailNotificationTypes,
+			showTimelineReplies: user.showTimelineReplies,
+			achievements: user.achievements, 
+			loggedInDays: user.loggedInDays,
+			policies: user.policies,
+			...(security ? {
+				email: user.email,
+				emailVerified: user.emailVerified,
+				securityKeysList: user.securityKeysList,
+			} : {}),
+		});
+	};
+
+	let app: INestApplicationContext;
+
+	let root: User;
+	let alice: User;
+	let aliceNote: misskey.entities.Note;
+	let alicePage: misskey.entities.Page;
+	let aliceList: misskey.entities.UserList;
+
+	let bob: User;
+	let bobNote: misskey.entities.Note;
+
+	let carol: User;
+	let dave: User;
+	let ellen: User;
+	let frank: User;
+
+	let usersReplying: User[];
+
+	let userNoNote: User;
+	let userNotExplorable: User;
+	let userLocking: User;
+	let userAdmin: User;
+	let roleAdmin: any;
+	let userModerator: User;
+	let roleModerator: any;
+	let userRolePublic: User;
+	let rolePublic: any;
+	let userRoleBadge: User;
+	let roleBadge: any;
+	let userSilenced: User;
+	let roleSilenced: any;
+	let userSuspended: User;
+	let userDeletedBySelf: User;
+	let userDeletedByAdmin: User;
+	let userFollowingAlice: User;
+	let userFollowedByAlice: User;
+	let userBlockingAlice: User;
+	let userBlockedByAlice: User;
+	let userMutingAlice: User;
+	let userMutedByAlice: User;
+	let userRnMutingAlice: User;
+	let userRnMutedByAlice: User;
+	let userFollowRequesting: User;
+	let userFollowRequested: User;
+
+	beforeAll(async () => {
+		app = await startServer();
+	}, 1000 * 60 * 2);
+
+	beforeAll(async () => {
+		root = await signup({ username: 'alice' });
+		alice = root;
+		aliceNote = await post(alice, { text: 'test' }) as any; 
+		alicePage = await page(alice);
+		aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
+		bob = await signup({ username: 'bob' });
+		bobNote = await post(bob, { text: 'test' }) as any; 
+		carol = await signup({ username: 'carol' });
+		dave = await signup({ username: 'dave' });
+		ellen = await signup({ username: 'ellen' });
+		frank = await signup({ username: 'frank' });
+
+		// @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする
+		usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => {
+			const u = await signup({ username: `replying${i}` });
+			for (let j = 0; j < 10 - i; j++) {
+				const p = await post(u, { text: `test${j}` }); 
+				await post(alice, { text: `@${u.username} test${j}`, replyId: p.id });
+			}
+			
+			return (await acc).concat(u);
+		}, Promise.resolve([] as User[]));
+
+		userNoNote = await signup({ username: 'userNoNote' });
+		userNotExplorable = await signup({ username: 'userNotExplorable' });
+		await post(userNotExplorable, { text: 'test' });
+		await api('i/update', { isExplorable: false }, userNotExplorable);
+		userLocking = await signup({ username: 'userLocking' });
+		await post(userLocking, { text: 'test' });
+		await api('i/update', { isLocked: true }, userLocking);
+		userAdmin = await signup({ username: 'userAdmin' });
+		roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' });
+		await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root);
+		userModerator = await signup({ username: 'userModerator' });
+		roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' });
+		await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root);
+		userRolePublic = await signup({ username: 'userRolePublic' });
+		rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
+		await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
+		userRoleBadge = await signup({ username: 'userRoleBadge' });
+		roleBadge = await role(root, { asBadge: true, name: 'Badge Role' });
+		await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
+		userSilenced = await signup({ username: 'userSilenced' });
+		await post(userSilenced, { text: 'test' });
+		roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
+		await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
+		userSuspended = await signup({ username: 'userSuspended' });
+		await post(userSuspended, { text: 'test' });
+		await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
+		await api('admin/suspend-user', { userId: userSuspended.id }, root);
+		userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
+		await post(userDeletedBySelf, { text: 'test' });
+		await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
+		userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
+		await post(userDeletedByAdmin, { text: 'test' });
+		await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
+		userFollowingAlice = await signup({ username: 'userFollowingAlice' });
+		await post(userFollowingAlice, { text: 'test' });
+		await api('following/create', { userId: alice.id }, userFollowingAlice);
+		userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
+		await post(userFollowedByAlice, { text: 'test' });
+		await api('following/create', { userId: userFollowedByAlice.id }, alice);
+		userBlockingAlice = await signup({ username: 'userBlockingAlice' });
+		await post(userBlockingAlice, { text: 'test' });
+		await api('blocking/create', { userId: alice.id }, userBlockingAlice);
+		userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
+		await post(userBlockedByAlice, { text: 'test' });
+		await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
+		userMutingAlice = await signup({ username: 'userMutingAlice' });
+		await post(userMutingAlice, { text: 'test' });
+		await api('mute/create', { userId: alice.id }, userMutingAlice);
+		userMutedByAlice = await signup({ username: 'userMutedByAlice' });
+		await post(userMutedByAlice, { text: 'test' });
+		await api('mute/create', { userId: userMutedByAlice.id }, alice);
+		userRnMutingAlice = await signup({ username: 'userRnMutingAlice' });
+		await post(userRnMutingAlice, { text: 'test' });
+		await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice);
+		userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' });
+		await post(userRnMutedByAlice, { text: 'test' });
+		await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice);
+		userFollowRequesting = await signup({ username: 'userFollowRequesting' });
+		await post(userFollowRequesting, { text: 'test' });
+		userFollowRequested = userLocking;
+		await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting);
+	}, 1000 * 60 * 10);
+
+	afterAll(async () => {
+		await app.close();
+	});
+
+	beforeEach(async () => {
+		alice = {
+			...alice,
+			...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any,
+		};
+		aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice });
+	});
+
+	//#region サインアップ(signup)
+
+	test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => {
+		// SignupApiService.ts
+		const response = await successfulApiCall({
+			endpoint: 'signup',
+			parameters: { username: 'zoe', password: 'password' },
+			user: undefined,
+		}) as unknown as User; // BUG MeDetailedに足りないキーがある
+
+		// signupの時はtokenが含まれる特別なMeDetailedが返ってくる
+		assert.match(response.token, /[a-zA-Z0-9]{16}/);
+
+		// UserLite
+		assert.match(response.id, /[0-9a-z]{10}/);
+		assert.strictEqual(response.name, null);
+		assert.strictEqual(response.username, 'zoe');
+		assert.strictEqual(response.host, null);
+		assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+		assert.strictEqual(response.avatarBlurhash, null);
+		assert.strictEqual(response.isBot, false);
+		assert.strictEqual(response.isCat, false);
+		assert.strictEqual(response.instance, undefined);
+		assert.deepStrictEqual(response.emojis, {});
+		assert.strictEqual(response.onlineStatus, 'unknown');
+		assert.deepStrictEqual(response.badgeRoles, []);
+		// UserDetailedNotMeOnly
+		assert.strictEqual(response.url, null);
+		assert.strictEqual(response.uri, null);
+		assert.strictEqual(response.movedToUri, null);
+		assert.strictEqual(response.alsoKnownAs, null);
+		assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
+		assert.strictEqual(response.updatedAt, null);
+		assert.strictEqual(response.lastFetchedAt, null);
+		assert.strictEqual(response.bannerUrl, null);
+		assert.strictEqual(response.bannerBlurhash, null);
+		assert.strictEqual(response.isLocked, false);
+		assert.strictEqual(response.isSilenced, false);
+		assert.strictEqual(response.isSuspended, false);
+		assert.strictEqual(response.description, null);
+		assert.strictEqual(response.location, null);
+		assert.strictEqual(response.birthday, null);
+		assert.strictEqual(response.lang, null);
+		assert.deepStrictEqual(response.fields, []);
+		assert.strictEqual(response.followersCount, 0);
+		assert.strictEqual(response.followingCount, 0);
+		assert.strictEqual(response.notesCount, 0);
+		assert.deepStrictEqual(response.pinnedNoteIds, []);
+		assert.deepStrictEqual(response.pinnedNotes, []);
+		assert.strictEqual(response.pinnedPageId, null);
+		assert.strictEqual(response.pinnedPage, null);
+		assert.strictEqual(response.publicReactions, false);
+		assert.strictEqual(response.ffVisibility, 'public');
+		assert.strictEqual(response.twoFactorEnabled, false);
+		assert.strictEqual(response.usePasswordLessLogin, false);
+		assert.strictEqual(response.securityKeys, false);
+		assert.deepStrictEqual(response.roles, []);
+		
+		// MeDetailedOnly
+		assert.strictEqual(response.avatarId, null);
+		assert.strictEqual(response.bannerId, null);
+		assert.strictEqual(response.isModerator, false);
+		assert.strictEqual(response.isAdmin, false);
+		assert.strictEqual(response.injectFeaturedNote, true);
+		assert.strictEqual(response.receiveAnnouncementEmail, true);
+		assert.strictEqual(response.alwaysMarkNsfw, false);
+		assert.strictEqual(response.autoSensitive, false);
+		assert.strictEqual(response.carefulBot, false);
+		assert.strictEqual(response.autoAcceptFollowed, true);
+		assert.strictEqual(response.noCrawle, false);
+		assert.strictEqual(response.isExplorable, true);
+		assert.strictEqual(response.isDeleted, false);
+		assert.strictEqual(response.hideOnlineStatus, false);
+		assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
+		assert.strictEqual(response.hasUnreadMentions, false);
+		assert.strictEqual(response.hasUnreadAnnouncement, false);
+		assert.strictEqual(response.hasUnreadAntenna, false);
+		assert.strictEqual(response.hasUnreadChannel, false);
+		assert.strictEqual(response.hasUnreadNotification, false);
+		assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
+		assert.deepStrictEqual(response.mutedWords, []);
+		assert.deepStrictEqual(response.mutedInstances, []);
+		assert.deepStrictEqual(response.mutingNotificationTypes, []);
+		assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
+		assert.strictEqual(response.showTimelineReplies, false);
+		assert.deepStrictEqual(response.achievements, []);
+		assert.deepStrictEqual(response.loggedInDays, 0);
+		assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); 
+		assert.notStrictEqual(response.email, undefined);
+		assert.strictEqual(response.emailVerified, false);
+		assert.deepStrictEqual(response.securityKeysList, []);
+	});
+
+	//#endregion
+	//#region 自分の情報(i)
+
+	test('を読み取ることができる。(自分)', async () => {
+		const response = await successfulApiCall({
+			endpoint: 'i',
+			parameters: {},
+			user: userNoNote,
+		});
+		const expected = meDetailed(userNoNote, true);
+		expected.loggedInDays = 1; // iはloggedInDaysを更新する
+		assert.deepStrictEqual(response, expected);
+	});
+
+	//#endregion
+	//#region 自分の情報の更新(i/update)
+
+	test.each([
+		{ parameters: (): object => ({ name: null }) },
+		{ parameters: (): object => ({ name: 'x'.repeat(50) }) },
+		{ parameters: (): object => ({ name: 'x' }) },
+		{ parameters: (): object => ({ name: 'My name' }) },
+		{ parameters: (): object => ({ description: null }) },
+		{ parameters: (): object => ({ description: 'x'.repeat(1500) }) },
+		{ parameters: (): object => ({ description: 'x' }) },
+		{ parameters: (): object => ({ description: 'My description' }) },
+		{ parameters: (): object => ({ location: null }) },
+		{ parameters: (): object => ({ location: 'x'.repeat(50) }) },
+		{ parameters: (): object => ({ location: 'x' }) },
+		{ parameters: (): object => ({ location: 'My location' }) },
+		{ parameters: (): object => ({ birthday: '0000-00-00' }) },
+		{ parameters: (): object => ({ birthday: '9999-99-99' }) },
+		{ parameters: (): object => ({ lang: 'en-US' }) },
+		{ parameters: (): object => ({ fields: [] }) },
+		{ parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) },
+		{ parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない
+		{ parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) },
+		{ parameters: (): object => ({ isLocked: true }) },
+		{ parameters: (): object => ({ isLocked: false }) },
+		{ parameters: (): object => ({ isExplorable: false }) },
+		{ parameters: (): object => ({ isExplorable: true }) },
+		{ parameters: (): object => ({ hideOnlineStatus: true }) },
+		{ parameters: (): object => ({ hideOnlineStatus: false }) },
+		{ parameters: (): object => ({ publicReactions: false }) },
+		{ parameters: (): object => ({ publicReactions: true }) },
+		{ parameters: (): object => ({ autoAcceptFollowed: true }) },
+		{ parameters: (): object => ({ autoAcceptFollowed: false }) },
+		{ parameters: (): object => ({ noCrawle: true }) },
+		{ parameters: (): object => ({ noCrawle: false }) },
+		{ parameters: (): object => ({ isBot: true }) },
+		{ parameters: (): object => ({ isBot: false }) },
+		{ parameters: (): object => ({ isCat: true }) },
+		{ parameters: (): object => ({ isCat: false }) },
+		{ parameters: (): object => ({ showTimelineReplies: true }) },
+		{ parameters: (): object => ({ showTimelineReplies: false }) },
+		{ parameters: (): object => ({ injectFeaturedNote: true }) },
+		{ parameters: (): object => ({ injectFeaturedNote: false }) },
+		{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
+		{ parameters: (): object => ({ receiveAnnouncementEmail: false }) },
+		{ parameters: (): object => ({ alwaysMarkNsfw: true }) },
+		{ parameters: (): object => ({ alwaysMarkNsfw: false }) },
+		{ parameters: (): object => ({ autoSensitive: true }) },
+		{ parameters: (): object => ({ autoSensitive: false }) },
+		{ parameters: (): object => ({ ffVisibility: 'private' }) },
+		{ parameters: (): object => ({ ffVisibility: 'followers' }) },
+		{ parameters: (): object => ({ ffVisibility: 'public' }) },
+		{ parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) },
+		{ parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) },
+		{ parameters: (): object => ({ mutedWords: [] }) },
+		{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
+		{ parameters: (): object => ({ mutedInstances: [] }) },
+		{ parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
+		{ parameters: (): object => ({ mutingNotificationTypes: [] }) },
+		{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
+		{ parameters: (): object => ({ emailNotificationTypes: [] }) },
+	] as const)('を書き換えることができる($#)', async ({ parameters }) => {
+		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice });
+		const expected = { ...meDetailed(alice, true), ...parameters() };
+		assert.deepStrictEqual(response, expected, inspect(parameters()));
+	});
+
+	test('を書き換えることができる(Avatar)', async () => {
+		const aliceFile = (await uploadFile(alice)).body;
+		const parameters = { avatarId: aliceFile.id };
+		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
+		assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+		assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/);
+		const expected = { 
+			...meDetailed(alice, true), 
+			avatarId: aliceFile.id,
+			avatarBlurhash: response.avatarBlurhash,
+			avatarUrl: response.avatarUrl,
+		};
+		assert.deepStrictEqual(response, expected, inspect(parameters));
+
+		if (1) return; // BUG 521eb95 以降アバターのリセットができない。
+		const parameters2 = { avatarId: null };
+		const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
+		const expected2 = { 
+			...meDetailed(alice, true), 
+			avatarId: null,
+			avatarBlurhash: null,
+			avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる
+		};
+		assert.deepStrictEqual(response2, expected2, inspect(parameters));
+	});
+
+	test('を書き換えることができる(Banner)', async () => {
+		const aliceFile = (await uploadFile(alice)).body;
+		const parameters = { bannerId: aliceFile.id };
+		const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
+		assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
+		assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/);
+		const expected = { 
+			...meDetailed(alice, true), 
+			bannerId: aliceFile.id,
+			bannerBlurhash: response.bannerBlurhash,
+			bannerUrl: response.bannerUrl,
+		};
+		assert.deepStrictEqual(response, expected, inspect(parameters));
+
+		if (1) return; // BUG 521eb95 以降バナーのリセットができない。
+		const parameters2 = { bannerId: null };
+		const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
+		const expected2 = { 
+			...meDetailed(alice, true), 
+			bannerId: null,
+			bannerBlurhash: null,
+			bannerUrl: null,
+		};
+		assert.deepStrictEqual(response2, expected2, inspect(parameters));
+	});
+
+	//#endregion
+	//#region 自分の情報の更新(i/pin, i/unpin)
+
+	test('を書き換えることができる(ピン止めノート)', async () => {
+		const parameters = { noteId: aliceNote.id };
+		const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice });
+		const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] };
+		assert.deepStrictEqual(response, expected);
+		
+		const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice });
+		const expected2 = meDetailed(alice, false);
+		assert.deepStrictEqual(response2, expected2);
+	});
+
+	//#endregion
+	//#region ユーザー(users)
+
+	test.each([
+		{ label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id },
+		{ label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+		{ label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+		{ label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+		{ label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+		{ label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+		{ label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+	] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => {
+		const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
+
+		// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
+		const users = await Promise.all(response.map(u => show(u.id)));
+		const expected = users.sort((x, y) => {
+			const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
+			return index * (parameters.sort?.startsWith('+') ? -1 : 1);
+		});
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true },
+		{ label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true },
+		{ label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true },
+		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+	] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
+		const parameters = { limit: 100 };
+		const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
+		const expected = (excluded ?? false) ? [] : [await show(user().id)];
+		assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
+	});
+	test.todo('をリスト形式で取得することができる(リモート, hostname指定)');
+	test.todo('をリスト形式で取得することができる(pagenation)');
+	
+	//#endregion
+	//#region ユーザー情報(users/show)
+
+	test.each([
+		{ label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed },
+		{ label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations },
+		{ label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe },
+		{ label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed },
+		{ label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations },
+		{ label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe },
+	] as const)('を取得することができる($label)', async ({ parameters, user, type }) => {
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() });
+		const expected = type(alice);
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin },
+		{ label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined },
+		{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
+		{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
+		{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
+		{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
+		{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
+		{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
+		{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
+		{ label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
+		{ label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing },
+		{ label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed },
+		{ label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking },
+		{ label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked },
+		{ label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted },
+		{ label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted },
+		{ label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou },
+		{ label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou },
+	] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => {
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice });
+		assert.strictEqual(selector(response), (expected ?? ((): true => true))());
+	});
+	test('を取得することができ、Publicなロールがセットされていること', async () => {
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice });
+		assert.deepStrictEqual(response.badgeRoles, []);
+		assert.deepStrictEqual(response.roles, [{
+			id: rolePublic.id,
+			name: rolePublic.name,
+			color: rolePublic.color,
+			iconUrl: rolePublic.iconUrl,
+			description: rolePublic.description,
+			isModerator: rolePublic.isModerator,
+			isAdministrator: rolePublic.isAdministrator,
+			displayOrder: rolePublic.displayOrder,
+		}]);
+	});
+	test('を取得することができ、バッヂロールがセットされていること', async () => {
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
+		assert.deepStrictEqual(response.badgeRoles, [{
+			name: roleBadge.name,
+			iconUrl: roleBadge.iconUrl,
+			displayOrder: roleBadge.displayOrder,
+		}]);
+		assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
+	});
+	test('をID指定のリスト形式で取得することができる(空)', async () => {
+		const parameters = { userIds: [] };
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
+		const expected: [] = [];
+		assert.deepStrictEqual(response, expected);
+	});
+	test('をID指定のリスト形式で取得することができる', async() => {
+		const parameters = { userIds: [bob.id, alice.id, carol.id] };
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
+		const expected = [
+			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), 
+			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), 
+			await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), 
+		];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root },
+		// BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる
+		//{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },	
+	] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => {
+		const parameters = { userIds: [user().id] };
+		const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice });
+		const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.todo('をID指定のリスト形式で取得することができる(リモート)');
+
+	//#endregion
+	//#region 検索(users/search)
+
+	test('を検索することができる', async () => {
+		const parameters = { query: 'carol', limit: 10 };
+		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+		const expected = [await show(carol.id)];
+		assert.deepStrictEqual(response, expected);
+	});
+	test('を検索することができる(UserLite)', async () => {
+		const parameters = { query: 'carol', detail: false, limit: 10 };
+		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+		const expected = [userLite(await show(carol.id))];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },	
+	] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
+		const parameters = { query: user().username, limit: 1 };
+		const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
+		const expected = (excluded ?? false) ? [] : [await show(user().id)];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.todo('を検索することができる(リモート)');
+	test.todo('を検索することができる(pagenation)');
+
+	//#endregion
+	//#region ID指定検索(users/search-by-username-and-host)
+
+	test.each([ 
+		{ label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] },
+		{ label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] },
+		{ label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] },
+		{ label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] },
+		{ label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] },
+		{ label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] },
+		{ label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] },
+		{ label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] },
+		{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
+	])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
+		const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
+		const expected = await Promise.all(user().map(u => show(u.id)));
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+	] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
+		const parameters = { username: user().username };
+		const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
+		const expected = (excluded ?? false) ? [] : [await show(user().id)];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.todo('をID&ホスト指定で検索できる(リモート)');
+
+	//#endregion
+	//#region ID指定検索(users/get-frequently-replied-users)
+
+	test('がよくリプライをするユーザーのリストを取得できる', async () => {
+		const parameters = { userId: alice.id, limit: 5 };
+		const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
+		const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ 
+			user: await show(s.id),
+			weight: (usersReplying.length - i) / usersReplying.length,
+		})));
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+		{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+	] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
+		const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0];
+		await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
+		const parameters = { userId: alice.id, limit: 100 };
+		const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
+		const expected = (excluded ?? false) ? [] : [await show(user().id)];
+		assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
+	});
+
+	//#endregion
+	//#region ハッシュタグ(hashtags/users)
+
+	test.each([
+		{ label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+		{ label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
+		{ label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+		{ label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
+		{ label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+		{ label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
+	] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => {
+		const hashtag = 'test_hashtag';
+		await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
+		const parameters = { tag: hashtag, limit: 5, ...sort };
+		const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
+		const users = await Promise.all(response.map(u => show(u.id)));
+		const expected = users.sort((x, y) => {
+			const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
+			return index * (parameters.sort.startsWith('+') ? -1 : 1);
+		});
+		assert.deepStrictEqual(response, expected);
+	});
+	test.each([
+		{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
+		{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
+		{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
+		{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
+		{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
+		{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
+		{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+		{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
+		{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
+	] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
+		const hashtag = `user_test${user().username}`;
+		if (user() !== userSuspended) {
+			// サスペンドユーザーはupdateできない。
+			await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() });
+		}
+		const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
+		const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
+		const expected = [await show(user().id)];
+		assert.deepStrictEqual(response, expected);
+	});
+	test.todo('をハッシュタグ指定で取得することができる(リモート)');
+
+	//#endregion
+	//#region オススメユーザー(users/recommendation)
+
+	// BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note"
+	test.skip('のオススメを取得することができる', async () => {
+		const parameters = {};
+		const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice });
+		const expected = await Promise.all(response.map(u => show(u.id)));
+		assert.deepStrictEqual(response, expected);
+	});
+
+	//#endregion
+	//#region ピン止めユーザー(pinned-users)
+
+	test('のピン止めユーザーを取得することができる', async () => {
+		await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
+		const parameters = {} as const;
+		const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
+		const expected = await Promise.all([bob, carol].map(u => show(u.id)));
+		assert.deepStrictEqual(response, expected);
+	});
+
+	//#endregion
+
+	test.todo('を管理人として確認することができる(admin/show-user)');
+	test.todo('を管理人として確認することができる(admin/show-users)');
+	test.todo('をサーバー向けに取得することができる(federation/users)');
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 4f501a8726..809ed2c66c 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -6,6 +6,7 @@ import WebSocket from 'ws';
 import fetch, { Blob, File, RequestInit } from 'node-fetch';
 import { DataSource } from 'typeorm';
 import { JSDOM } from 'jsdom';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
 import { entities } from '../src/postgres.js';
 import { loadConfig } from '../src/config.js';
 import type * as misskey from 'misskey-js';
@@ -31,12 +32,12 @@ export type ApiRequest = {
 };
 
 export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
-	status: number,
-} = { status: 200 }): Promise<T> => {
+	status?: number,
+} = {}): Promise<T> => {
 	const { endpoint, parameters, user } = request;
-	const { status } = assertion;
 	const res = await api(endpoint, parameters, user);
-	assert.strictEqual(res.status, status, inspect(res.body));
+	const status = assertion.status ?? (res.body == null ? 204 : 200);
+	assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
 	return res.body;
 };
 
@@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
 	return res.body;
 };
 
+export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => {
+	const res = await api('admin/roles/create', {
+		asBadge: false,
+		canEditMembersByModerator: false,
+		color: null,
+		condFormula: {
+			id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85',
+			type: 'isRemote',
+		},
+		description: '',
+		displayOrder: 0,
+		iconUrl: null,
+		isAdministrator: false,
+		isModerator: false,
+		isPublic: false,
+		name: 'New Role',
+		target: 'manual',
+		policies: { 
+			...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { 
+				priority: 0,
+				useDefault: true,
+				value: v,
+			}]),
+			...policies,
+		},
+		...role,
+	}, user);
+	return res.body;
+};
+
 interface UploadOptions {
 	/** Optional, absolute path or relative from ./resources/ */
 	path?: string | URL;
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 6741e7a18b..fb0a3a4b67 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -15,6 +15,7 @@ const props = defineProps<{
 	list?: string;
 	antenna?: string;
 	channel?: string;
+	role?: string;
 	sound?: boolean;
 }>();
 
@@ -121,6 +122,15 @@ if (props.src === 'antenna') {
 		channelId: props.channel,
 	});
 	connection.on('note', prepend);
+} else if (props.src === 'role') {
+	endpoint = 'roles/notes';
+	query = {
+		roleId: props.role,
+	};
+	connection = stream.useChannel('roleTimeline', {
+		roleId: props.role,
+	});
+	connection.on('note', prepend);
 }
 
 const pagination = {
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index 2131188dde..5f3728b677 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -1,7 +1,7 @@
 <template>
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<div class="lznhrdub">
+	<div>
 		<div v-if="tab === 'featured'">
 			<XFeatured/>
 		</div>
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index 2e9d3d6169..f2645394a2 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -1,13 +1,16 @@
 <template>
 <MkStickyContainer>
-	<template #header><MkPageHeader/></template>
+	<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
 
-	<MkSpacer :content-max="1200">
+	<MkSpacer v-if="tab === 'users'" :content-max="1200">
 		<div class="_gaps_s">
 			<div v-if="role">{{ role.description }}</div>
 			<MkUserList :pagination="users" :extractor="(item) => item.user"/>
 		</div>
 	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'timeline'" :content-max="700">
+		<MkTimeline ref="timeline" src="role" :role="props.role"/>
+	</MkSpacer>
 </MkStickyContainer>
 </template>
 
@@ -16,11 +19,17 @@ import { computed, watch } from 'vue';
 import * as os from '@/os';
 import MkUserList from '@/components/MkUserList.vue';
 import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import MkTimeline from '@/components/MkTimeline.vue';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
 	role: string;
-}>();
+	initialTab?: string;
+}>(), {
+	initialTab: 'users',
+});
 
+let tab = $ref(props.initialTab);
 let role = $ref();
 
 watch(() => props.role, () => {
@@ -39,6 +48,16 @@ const users = $computed(() => ({
 	},
 }));
 
+const headerTabs = $computed(() => [{
+	key: 'users',
+	icon: 'ti ti-users',
+	title: i18n.ts.users,
+}, {
+	key: 'timeline',
+	icon: 'ti ti-pencil',
+	title: i18n.ts.timeline,
+}]);
+
 definePageMetadata(computed(() => ({
 	title: role?.name,
 	icon: 'ti ti-badge',
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 4db7c9413a..33e752513b 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -152,6 +152,7 @@ const addColumn = async (ev) => {
 		'channel',
 		'mentions',
 		'direct',
+		'roleTimeline',
 	];
 
 	const { canceled, result: column } = await os.select({
diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue
index 083e91bb03..8e7addf359 100644
--- a/packages/frontend/src/ui/deck/column-core.vue
+++ b/packages/frontend/src/ui/deck/column-core.vue
@@ -10,6 +10,7 @@
 <XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 <XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 <XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
+<XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
 </template>
 
 <script lang="ts" setup>
@@ -23,6 +24,7 @@ import XNotificationsColumn from './notifications-column.vue';
 import XWidgetsColumn from './widgets-column.vue';
 import XMentionsColumn from './mentions-column.vue';
 import XDirectColumn from './direct-column.vue';
+import XRoleTimelineColumn from './role-timeline-column.vue';
 import { Column } from './deck-store';
 
 defineProps<{
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 1420ad8b30..a6784e9849 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -22,6 +22,7 @@ export type Column = {
 	antennaId?: string;
 	listId?: string;
 	channelId?: string;
+	roleId?: string;
 	includingTypes?: typeof notificationTypes[number][];
 	tl?: 'home' | 'local' | 'social' | 'global';
 };
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
new file mode 100644
index 0000000000..5783b3f071
--- /dev/null
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -0,0 +1,67 @@
+<template>
+<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+	<template #header>
+		<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import XColumn from './column.vue';
+import { updateColumn, Column } from './deck-store';
+import MkTimeline from '@/components/MkTimeline.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	column: Column;
+	isStacked: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'loaded'): void;
+	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
+}>();
+
+let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
+
+onMounted(() => {
+	if (props.column.roleId == null) {
+		setRole();
+	}
+});
+
+async function setRole() {
+	const roles = await os.api('roles/list');
+	const { canceled, result: role } = await os.select({
+		title: i18n.ts.role,
+		items: roles.map(x => ({
+			value: x, text: x.name,
+		})),
+		default: props.column.roleId,
+	});
+	if (canceled) return;
+	updateColumn(props.column.id, {
+		roleId: role.id,
+	});
+}
+
+const menu = [{
+	icon: 'ti ti-pencil',
+	text: i18n.ts.role,
+	action: setRole,
+}];
+
+/*
+function focus() {
+	timeline.focus();
+}
+
+defineExpose({
+	focus,
+});
+*/
+</script>