From 19605600d3dc7fbdbf2a95df283a56470f55277b Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:46:49 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat(backend):=20=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=B8=B8=E6=99=82=E8=A1=A8=E7=A4=BA=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1779035944722-RoleDisplayVisibility.js | 24 +++++++++++++++++++ packages/backend/src/core/RoleService.ts | 1 + .../src/core/entities/RoleEntityService.ts | 2 +- packages/backend/src/models/Role.ts | 5 ++++ .../backend/src/models/json-schema/role.ts | 15 ++++++++++++ .../api/endpoints/admin/roles/create.ts | 1 + .../api/endpoints/admin/roles/update.ts | 2 ++ 7 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1779035944722-RoleDisplayVisibility.js diff --git a/packages/backend/migration/1779035944722-RoleDisplayVisibility.js b/packages/backend/migration/1779035944722-RoleDisplayVisibility.js new file mode 100644 index 00000000000..74b644a9f47 --- /dev/null +++ b/packages/backend/migration/1779035944722-RoleDisplayVisibility.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleDisplayVisibility1779035944722 { + name = 'RoleDisplayVisibility1779035944722'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`); + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index a57449157b4..13f6cb4694d 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -670,6 +670,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { target: values.target, condFormula: values.condFormula, isPublic: values.isPublic, + isPublicDisplayRequired: values.isPublicDisplayRequired ?? false, isAdministrator: values.isAdministrator, isModerator: values.isModerator, isExplorable: values.isExplorable, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 3fa38c9521f..f4b6fdd44e1 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -64,6 +64,7 @@ export class RoleEntityService { target: role.target, condFormula: role.condFormula, isPublic: role.isPublic, + isPublicDisplayRequired: role.isPublicDisplayRequired, isAdministrator: role.isAdministrator, isModerator: role.isModerator, isExplorable: role.isExplorable, @@ -84,4 +85,3 @@ export class RoleEntityService { return Promise.all(roles.map(x => this.pack(x, me))); } } - diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index 4c7da252bdd..dac5a1c0f1e 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -227,6 +227,11 @@ export class MiRole { }) public isPublic: boolean; + @Column('boolean', { + default: false, + }) + public isPublicDisplayRequired: boolean; + // trueの場合ユーザー名の横にバッジとして表示 @Column('boolean', { default: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index eaed3ac7107..ef44e2b0658 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -369,6 +369,16 @@ export const packedRoleLiteSchema = { optional: false, nullable: false, example: false, }, + asBadge: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, + isPublicDisplayRequired: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, displayOrder: { type: 'integer', optional: false, nullable: false, @@ -412,6 +422,11 @@ export const packedRoleSchema = { optional: false, nullable: false, example: false, }, + isPublicDisplayRequired: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, isExplorable: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index f92f7ebaeb2..af8879ee9e1 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -32,6 +32,7 @@ export const paramDef = { target: { type: 'string', enum: ['manual', 'conditional'] }, condFormula: { type: 'object' }, isPublic: { type: 'boolean' }, + isPublicDisplayRequired: { type: 'boolean', default: false }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 175adcb63f1..72b7981a226 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -37,6 +37,7 @@ export const paramDef = { target: { type: 'string', enum: ['manual', 'conditional'] }, condFormula: { type: 'object' }, isPublic: { type: 'boolean' }, + isPublicDisplayRequired: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean' }, @@ -75,6 +76,7 @@ export default class extends Endpoint { // eslint- target: ps.target, condFormula: ps.condFormula, isPublic: ps.isPublic, + isPublicDisplayRequired: ps.isPublicDisplayRequired, isModerator: ps.isModerator, isAdministrator: ps.isAdministrator, isExplorable: ps.isExplorable, From f8e28df7114c3b0f891125878b0fb7dc8e09d99f Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:46:56 +0900 Subject: [PATCH 02/31] =?UTF-8?q?feat(backend):=20=E5=85=AC=E9=96=8B?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=AE=E9=9D=9E=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/WebhookTestService.ts | 1 + .../src/core/entities/UserEntityService.ts | 42 +++++++++++++++++-- packages/backend/src/models/User.ts | 6 +++ .../backend/src/models/json-schema/user.ts | 14 +++++++ .../src/server/api/endpoints/i/update.ts | 20 +++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index b112912b1b6..8b259841dd9 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -55,6 +55,7 @@ function generateDummyUser(override?: Partial): MiUser { makeNotesHiddenBefore: null, chatScope: 'mutual', emojis: [], + hiddenRoleIds: [], score: 0, host: null, inbox: null, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e8..60ab5528d1d 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -14,6 +14,7 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; +import type { MiRole } from '@/models/Role.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { birthdaySchema, @@ -402,6 +403,37 @@ export class UserEntityService implements OnModuleInit { return `${this.config.url}/users/${userId}`; } + @bindThis + private filterHiddenDisplayRoles>( + roles: T[], + user: MiUser, + iAmModerator: boolean, + shouldFilterHidden: boolean, + ): T[] { + if (iAmModerator || !shouldFilterHidden || user.hiddenRoleIds.length === 0) return roles; + + const hiddenRoleIds = new Set(user.hiddenRoleIds); + return roles.filter(role => role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id)); + } + + @bindThis + private sanitizeHiddenRoleIds(roles: Pick[], user: MiUser): MiRole['id'][] { + const hideableRoleIds = new Set(roles + .filter(role => role.isPublic && !role.isPublicDisplayRequired) + .map(role => role.id)); + const sanitizedRoleIds: MiRole['id'][] = []; + const seenRoleIds = new Set(); + + for (const roleId of user.hiddenRoleIds) { + if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue; + + sanitizedRoleIds.push(roleId); + seenRoleIds.add(roleId); + } + + return sanitizedRoleIds; + } + public async pack( src: MiUser['id'] | MiUser, me?: { id: MiUser['id']; } | null | undefined, @@ -425,6 +457,7 @@ export class UserEntityService implements OnModuleInit { const meId = me ? me.id : null; const isMe = meId === user.id; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; + const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null; const profile = isDetailed ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) @@ -514,10 +547,10 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得 - badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs - .filter((r) => r.isPublic || iAmModerator) + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, iAmModerator, true) .sort((a, b) => b.displayOrder - a.displayOrder) .map((r) => ({ + id: r.id, name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, @@ -560,7 +593,7 @@ export class UserEntityService implements OnModuleInit { followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ + roles: userRoles!.then(roles => this.filterHiddenDisplayRoles(roles.filter(role => role.isPublic), user, iAmModerator, !isMe).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, color: role.color, @@ -568,6 +601,8 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + asBadge: role.asBadge, + isPublicDisplayRequired: role.isPublicDisplayRequired, displayOrder: role.displayOrder, }))), memo: memo, @@ -598,6 +633,7 @@ export class UserEntityService implements OnModuleInit { preventAiLearning: profile!.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, + hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)), twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: false, // 後方互換性のため diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 084dd354850..d8f1ba6ad2a 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -6,6 +6,7 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; +import type { MiRole } from './Role.js'; @Entity('user') @Index(['usernameLower', 'host'], { unique: true }) @@ -229,6 +230,11 @@ export class MiUser { }) public emojis: string[]; + @Column('varchar', { + length: 32, array: true, default: '{}', + }) + public hiddenRoleIds: MiRole['id'][]; + // チャットを許可する相手 // everyone: 誰からでも // followers: フォロワーのみ diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f71ec1d023e..05acaccc41d 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -176,6 +176,11 @@ export const packedUserLiteSchema = { type: 'object', nullable: false, optional: false, properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, name: { type: 'string', nullable: false, optional: false, @@ -511,6 +516,15 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hiddenRoleIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, twoFactorBackupCodesStock: { type: 'string', enum: ['full', 'partial', 'none'], diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5207d9f2b0a..c40e49eeab4 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -191,6 +191,12 @@ export const paramDef = { followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] }, + hiddenRoleIds: { + type: 'array', + maxItems: 256, + uniqueItems: true, + items: { type: 'string', format: 'misskey:id' }, + }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: muteWords, hardMutedWords: muteWords, @@ -293,6 +299,20 @@ export default class extends Endpoint { // eslint- if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; + if (ps.hiddenRoleIds !== undefined) { + const roles = await this.roleService.getUserRoles(user.id); + const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id)); + const hiddenRoleIds: string[] = []; + const seenRoleIds = new Set(); + + for (const roleId of ps.hiddenRoleIds) { + if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue; + seenRoleIds.add(roleId); + hiddenRoleIds.push(roleId); + } + + updates.hiddenRoleIds = hiddenRoleIds; + } function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { const count = (arr: (string[] | string)[]) => { From d0b894072347e633bbac16d8a5313b2e2f008d23 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:47:05 +0900 Subject: [PATCH 03/31] =?UTF-8?q?feat(frontend):=20=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E8=A1=A8=E7=A4=BA=E8=A8=AD=E5=AE=9A=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/ja-JP.yml | 8 ++ packages/frontend/.storybook/fakes.ts | 2 + .../frontend/src/components/MkNoteHeader.vue | 13 ++- .../frontend/src/pages/admin/roles.edit.vue | 3 +- .../frontend/src/pages/admin/roles.editor.vue | 8 +- .../frontend/src/pages/settings/privacy.vue | 102 ++++++++++++++++ .../src/pages/user/home.stories.impl.ts | 110 ++++++++++++++---- packages/frontend/src/pages/user/home.vue | 18 ++- packages/i18n/src/autogen/locale.ts | 26 +++++ packages/misskey-js/src/autogen/types.ts | 13 +++ 10 files changed, 274 insertions(+), 29 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 26ef054b9f1..845bc38cd1f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2074,6 +2074,8 @@ _role: isConditionalRole: "これはコンディショナルロールです。" isPublic: "公開ロール" descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。" + isPublicDisplayRequired: "非表示を許可しない(常に表示)" + descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。" options: "オプション" policies: "ポリシー" baseRole: "ベースロール" @@ -3224,6 +3226,12 @@ _gridComponent: patternNotMatch: "この値は{pattern}のパターンに一致しません" notUnique: "この値は一意である必要があります" +_roleDisplay: + title: "表示するロール/ロールバッジ" + description: "自分のプロフィールやノートに表示する公開ロールを選択します。" + alwaysShownByAdmin: "管理者により常に表示するよう設定されています。" + noRoles: "表示できる公開ロールはありません。" + _roleSelectDialog: notSelected: "選択されていません" diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 723255267b4..f5c5dafa8e9 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -357,6 +357,7 @@ export function role(params: { isPublic?: boolean, isExplorable?: boolean, asBadge?: boolean, + isPublicDisplayRequired?: boolean, canEditMembersByModerator?: boolean, usersCount?: number, }, seed?: string): entities.Role { @@ -380,6 +381,7 @@ export function role(params: { isPublic: params.isPublic ?? true, isExplorable: params.isExplorable ?? true, asBadge: params.asBadge ?? true, + isPublicDisplayRequired: params.isPublicDisplayRequired ?? false, canEditMembersByModerator: params.canEditMembersByModerator ?? false, usersCount: params.usersCount ?? 10, preserveAssignmentOnMoveAccount: false, diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index ea115c2cd88..7c4f18ae8e1 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
bot
-
- +
+
@@ -35,17 +35,22 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index e806f68162b..58e1f65e384 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -37,7 +37,7 @@ const props = defineProps<{ id?: string; }>(); -type RoleLike = Pick & { +type RoleLike = Pick & { condFormula: any; policies: any; }; @@ -62,6 +62,7 @@ if (props.id) { target: 'manual', condFormula: { id: genId(), type: 'isRemote' }, isPublic: false, + isPublicDisplayRequired: false, isExplorable: false, asBadge: false, canEditMembersByModerator: false, diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 2a11160ef14..0c6ba8213c7 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -62,6 +62,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + @@ -110,7 +115,7 @@ import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; import type { PolicyMeta } from './roles.policy-editor.vue'; -type RoleLike = Pick & { +type RoleLike = Pick & { id?: Misskey.entities.Role['id'] | null; condFormula: any; policies: any; @@ -200,6 +205,7 @@ const save = throttle(100, () => { isAdministrator: role.value.isAdministrator, isModerator: role.value.isModerator, isPublic: role.value.isPublic, + isPublicDisplayRequired: role.value.isPublicDisplayRequired, isExplorable: role.value.isExplorable, asBadge: role.value.asBadge, canEditMembersByModerator: role.value.canEditMembersByModerator, diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index edc71e51563..0d8ac678a9c 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -51,6 +51,41 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + +
+ {{ i18n.ts._roleDisplay.noRoles }} + + + + +
+
+
+ @@ -213,6 +248,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/user/home.stories.impl.ts b/packages/frontend/src/pages/user/home.stories.impl.ts index 66d35790411..dd86a0e566e 100644 --- a/packages/frontend/src/pages/user/home.stories.impl.ts +++ b/packages/frontend/src/pages/user/home.stories.impl.ts @@ -2,33 +2,83 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import type { StoryObj } from '@storybook/vue3'; + +import { expect, waitFor, within } from '@storybook/test'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../../.storybook/fakes.js'; import { commonHandlers } from '../../../.storybook/mocks.js'; import home_ from './home.vue'; +import type { StoryObj } from '@storybook/vue3'; +import { $i } from '@/i.js'; + +type UserDetailed = ReturnType; +type ProfileRole = UserDetailed['roles'][number] & { + isPublicDisplayRequired?: boolean; +}; +type MeRoleDisplay = NonNullable & { + hiddenRoleIds?: string[]; +}; + +function createProfileRole(id: string, name: string, color: string, isPublicDisplayRequired = false): ProfileRole { + return { + id, + name, + color, + iconUrl: null, + description: `${name} description`, + isModerator: false, + isAdministrator: false, + asBadge: false, + displayOrder: 0, + isPublicDisplayRequired, + }; +} + +const visibleRole = createProfileRole('role-display-visible', 'Visible Role', '#3b82f6'); +const hiddenRole = createProfileRole('role-display-hidden', 'Hidden Role', '#ef4444'); +const forcedRole = createProfileRole('role-display-forced', 'Forced Role', '#22c55e', true); + +const roleDisplayUser = { + ...userDetailed(), + roles: [visibleRole, hiddenRole, forcedRole], +}; + +function setStoryAccount(user: UserDetailed, hiddenRoleIds: string[]): void { + if ($i == null) return; + + Object.assign($i as MeRoleDisplay, { + id: user.id, + username: user.username, + host: user.host, + name: user.name, + hiddenRoleIds, + }); +} + +function renderHome(args: UserDetailedHomeArgs, hiddenRoleIds: string[] = []) { + return { + components: { + home_, + }, + setup() { + setStoryAccount(args.user, hiddenRoleIds); + + return { + props: args, + }; + }, + template: '', + }; +} + +type UserDetailedHomeArgs = { + user: UserDetailed; + disableNotes?: boolean; +}; + export const Default = { render(args) { - return { - components: { - home_, - }, - setup() { - return { - args, - }; - }, - computed: { - props() { - return { - ...this.args, - }; - }, - }, - template: '', - }; + return renderHome(args); }, args: { user: userDetailed(), @@ -79,3 +129,21 @@ export const Default = { }, }, } satisfies StoryObj; + +export const RoleDisplayVisibility = { + ...Default, + render(args) { + return renderHome(args, [hiddenRole.id]); + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + + await expect(await canvas.findByText(visibleRole.name)).toBeInTheDocument(); + await expect(await canvas.findByText(forcedRole.name)).toBeInTheDocument(); + await waitFor(() => expect(canvas.queryByText(hiddenRole.name)).not.toBeInTheDocument()); + }, + args: { + ...Default.args, + user: roleDisplayUser, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 64b03bc4bcb..cdcd577fbbf 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +
+ {{ role.name }} @@ -215,6 +215,13 @@ const props = withDefaults(defineProps<{ disableNotes: false, }); +type ProfileRole = Misskey.entities.UserDetailed['roles'][number] & { + isPublicDisplayRequired?: boolean; +}; +type MeDetailedWithRoleDisplay = Misskey.entities.MeDetailed & { + hiddenRoleIds?: string[]; +}; + const emit = defineEmits<{ (ev: 'showMoreFiles'): void; }>(); @@ -222,6 +229,13 @@ const emit = defineEmits<{ const router = useRouter(); const user = ref(props.user); +const visibleProfileRoles = computed(() => { + const roles = user.value.roles as ProfileRole[]; + if ($i == null || $i.id !== user.value.id) return roles; + + const hiddenRoleIds = new Set((($i as MeDetailedWithRoleDisplay).hiddenRoleIds) ?? []); + return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id)); +}); const narrow = ref(null); const rootEl = useTemplateRef('rootEl'); const bannerEl = useTemplateRef('bannerEl'); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index e13458c22d3..aa921aba2c3 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -7990,6 +7990,14 @@ export interface Locale extends ILocale { * ユーザーのプロフィールでこのロールが表示されます。 */ "descriptionOfIsPublic": string; + /** + * 非表示を許可しない(常に表示) + */ + "isPublicDisplayRequired": string; + /** + * 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。 + */ + "descriptionOfIsPublicDisplayRequired": string; /** * オプション */ @@ -12095,6 +12103,24 @@ export interface Locale extends ILocale { "notUnique": string; }; }; + "_roleDisplay": { + /** + * 表示するロール/ロールバッジ + */ + "title": string; + /** + * 自分のプロフィールやノートに表示する公開ロールを選択します。 + */ + "description": string; + /** + * 管理者により常に表示するよう設定されています。 + */ + "alwaysShownByAdmin": string; + /** + * 表示できる公開ロールはありません。 + */ + "noRoles": string; + }; "_roleSelectDialog": { /** * 選択されていません diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 4b6d26b63f2..989c340fed7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4025,6 +4025,8 @@ export type components = { /** @enum {string} */ onlineStatus: 'unknown' | 'online' | 'active' | 'offline'; badgeRoles?: { + /** Format: id */ + id: string; name: string; iconUrl: string | null; displayOrder: number; @@ -4115,6 +4117,7 @@ export type components = { preventAiLearning: boolean; isExplorable: boolean; isDeleted: boolean; + hiddenRoleIds: string[]; /** @enum {string} */ twoFactorBackupCodesStock: 'full' | 'partial' | 'none'; hideOnlineStatus: boolean; @@ -5286,6 +5289,10 @@ export type components = { isModerator: boolean; /** @example false */ isAdministrator: boolean; + /** @example false */ + asBadge: boolean; + /** @example false */ + isPublicDisplayRequired: boolean; /** @example 0 */ displayOrder: number; }; @@ -5300,6 +5307,8 @@ export type components = { /** @example false */ isPublic: boolean; /** @example false */ + isPublicDisplayRequired: boolean; + /** @example false */ isExplorable: boolean; /** @example false */ asBadge: boolean; @@ -10874,6 +10883,8 @@ export interface operations { target: 'manual' | 'conditional'; condFormula: Record; isPublic: boolean; + /** @default false */ + isPublicDisplayRequired?: boolean; isModerator: boolean; isAdministrator: boolean; /** @default false */ @@ -11209,6 +11220,7 @@ export interface operations { target?: 'manual' | 'conditional'; condFormula?: Record; isPublic?: boolean; + isPublicDisplayRequired?: boolean; isModerator?: boolean; isAdministrator?: boolean; isExplorable?: boolean; @@ -27589,6 +27601,7 @@ export interface operations { followersVisibility?: 'public' | 'followers' | 'private'; /** @enum {string} */ chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + hiddenRoleIds?: string[]; /** Format: misskey:id */ pinnedPageId?: string | null; mutedWords?: (string[] | string)[]; From 4263f62e7b5a83bd47af4d1afb6667a637b4cc53 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:47:15 +0900 Subject: [PATCH 04/31] =?UTF-8?q?test:=20=E3=83=AD=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E8=A8=AD=E5=AE=9A=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/roles/display-visibility.test.ts | 54 +++++++++++ .../src/server/api/endpoints/i/update.test.ts | 24 +++++ packages/backend/test/e2e/users.ts | 36 ++++++++ .../test/unit/entities/UserEntityService.ts | 89 +++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts create mode 100644 packages/backend/src/server/api/endpoints/i/update.test.ts diff --git a/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts b/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts new file mode 100644 index 00000000000..83a172fb158 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { describe, test, expect } from 'vitest'; +import { getValidator } from '../../../../../../test/prelude/get-api-validator.js'; +import { paramDef as createParamDef } from './create.js'; +import { paramDef as updateParamDef } from './update.js'; + +const VALID = true; +const INVALID = false; + +const baseCreateParams = { + name: 'Role', + description: '', + color: null, + iconUrl: null, + target: 'manual', + condFormula: { + id: 'ebef1684672d49b6ad821b3ec3784f85', + type: 'isRemote', + }, + isPublic: true, + isModerator: false, + isAdministrator: false, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, +} as const; + +describe('api:admin/roles display visibility validation', () => { + describe('create', () => { + const v = getValidator(createParamDef); + + test('Accept omitted isPublicDisplayRequired', () => expect(v({ ...baseCreateParams })).toBe(VALID)); + test('Accept false isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: false })).toBe(VALID)); + test('Accept true isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: true })).toBe(VALID)); + test('Reject non-boolean isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: 'true' })).toBe(INVALID)); + }); + + describe('update', () => { + const v = getValidator(updateParamDef); + const baseUpdateParams = { roleId: '9m4e2mr0ui' } as const; + + test('Accept omitted isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams })).toBe(VALID)); + test('Accept false isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: false })).toBe(VALID)); + test('Accept true isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: true })).toBe(VALID)); + test('Reject non-boolean isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: 'true' })).toBe(INVALID)); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.test.ts b/packages/backend/src/server/api/endpoints/i/update.test.ts new file mode 100644 index 00000000000..ee426f03046 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update.test.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { describe, test, expect } from 'vitest'; +import { getValidator } from '../../../../../test/prelude/get-api-validator.js'; +import { paramDef } from './update.js'; + +const VALID = true; +const INVALID = false; + +describe('api:i/update', () => { + describe('validation', () => { + const v = getValidator(paramDef); + + test('Accept hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['9m4e2mr0ui'] })).toBe(VALID)); + test('Reject malformed hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['not-valid:id'] })).toBe(INVALID)); + test('Reject too many hiddenRoleIds', () => expect(v({ hiddenRoleIds: Array.from({ length: 257 }, (_, i) => `role${i}`) })).toBe(INVALID)); + test('Reject duplicate hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['9m4e2mr0ui', '9m4e2mr0ui'] })).toBe(INVALID)); + }); +}); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index be5fb3b0a70..02f3ad2667e 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -128,6 +128,7 @@ describe('ユーザー', () => { preventAiLearning: user.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, + hiddenRoleIds: (user as any).hiddenRoleIds ?? [], twoFactorBackupCodesStock: user.twoFactorBackupCodesStock, hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, @@ -660,12 +661,15 @@ describe('ユーザー', () => { description: rolePublic.description, isModerator: rolePublic.isModerator, isAdministrator: rolePublic.isAdministrator, + asBadge: rolePublic.asBadge, + isPublicDisplayRequired: (rolePublic as any).isPublicDisplayRequired, displayOrder: rolePublic.displayOrder, }]); }); test('を取得することができ、バッヂロールがセットされていること', async () => { const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); assert.deepStrictEqual(response.badgeRoles, [{ + id: roleBadge.id, name: roleBadge.name, iconUrl: roleBadge.iconUrl, displayOrder: roleBadge.displayOrder, @@ -678,9 +682,41 @@ describe('ユーザー', () => { description: roleBadge.description, isModerator: roleBadge.isModerator, isAdministrator: roleBadge.isAdministrator, + asBadge: roleBadge.asBadge, + isPublicDisplayRequired: (roleBadge as any).isPublicDisplayRequired, displayOrder: roleBadge.displayOrder, }]); }); + test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => { + const user = await signup({ username: 'userHiddenRoles' }); + const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' }); + const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' }); + const privateRole = await role(root, { isPublic: false, name: 'Private Role' }); + const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' } as any); + const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' }); + + await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root); + await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root); + + const response = await successfulApiCall({ + endpoint: 'i/update', + parameters: { + hiddenRoleIds: [ + secondVisibleRole.id, + 'unknownroleid', + unassignedRole.id, + privateRole.id, + forcedRole.id, + visibleRole.id, + ], + } as any, + user, + }); + + assert.deepStrictEqual((response as any).hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]); + }); test('をID指定のリスト形式で取得することができる(空)', async () => { const parameters = { userIds: [] }; const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 1b5f9ed874c..0a41b6e7639 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -14,7 +14,10 @@ import { genAidx } from '@/misc/id/aidx.js'; import { BlockingsRepository, FollowingsRepository, FollowRequestsRepository, + MiRole, MiUserProfile, MutingsRepository, RenoteMutingsRepository, + RoleAssignmentsRepository, + RolesRepository, UserMemoRepository, UserProfilesRepository, UsersRepository, @@ -67,6 +70,9 @@ describe('UserEntityService', () => { let blockingRepository: BlockingsRepository; let mutingRepository: MutingsRepository; let renoteMutingsRepository: RenoteMutingsRepository; + let rolesRepository: RolesRepository; + let roleAssignmentsRepository: RoleAssignmentsRepository; + let roleService: RoleService; async function createUser(userData: Partial = {}, profileData: Partial = {}) { const un = secureRndstr(16); @@ -87,6 +93,32 @@ describe('UserEntityService', () => { return user; } + async function createRole(roleData: Partial = {}) { + const role = await rolesRepository + .insert({ + id: genAidx(Date.now()), + updatedAt: new Date(), + lastUsedAt: new Date(), + name: '', + description: '', + ...roleData, + }) + .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); + + (roleService as any).rolesCache.delete(); + return role; + } + + async function assignRole(user: MiUser, role: MiRole) { + await roleAssignmentsRepository.insert({ + id: genAidx(Date.now()), + userId: user.id, + roleId: role.id, + }); + + (roleService as any).roleAssignmentByUserIdCache.delete(user.id); + } + async function memo(writer: MiUser, target: MiUser, memo: string) { await userMemosRepository.insert({ id: genAidx(Date.now()), @@ -196,6 +228,9 @@ describe('UserEntityService', () => { blockingRepository = app.get(DI.blockingsRepository); mutingRepository = app.get(DI.mutingsRepository); renoteMutingsRepository = app.get(DI.renoteMutingsRepository); + rolesRepository = app.get(DI.rolesRepository); + roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); + roleService = app.get(RoleService); }); afterAll(async () => { @@ -249,6 +284,60 @@ describe('UserEntityService', () => { expect(actual.achievements).toEqual(achievements); }); + test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => { + const me = await createUser(); + const visibleRole = await createRole({ name: 'visible', isPublic: true }); + const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true }); + const privateRole = await createRole({ name: 'private', isPublic: false }); + const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true }); + const unassignedRole = await createRole({ name: 'unassigned', isPublic: true }); + + await assignRole(me, visibleRole); + await assignRole(me, secondVisibleRole); + await assignRole(me, privateRole); + await assignRole(me, forcedRole); + await usersRepository.update(me.id, { + hiddenRoleIds: [ + secondVisibleRole.id, + 'unknownroleid', + unassignedRole.id, + privateRole.id, + forcedRole.id, + visibleRole.id, + secondVisibleRole.id, + ], + }); + const updatedMe = await usersRepository.findOneByOrFail({ id: me.id }); + + const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' }) as any; + + expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]); + }); + + test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => { + const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]); + const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 }); + const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 }); + const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 }); + const moderatorRole = await createRole({ name: 'moderator', isModerator: true }); + + await assignRole(target, hiddenRole); + await assignRole(target, visibleRole); + await assignRole(target, forcedRole); + await assignRole(moderator, moderatorRole); + await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] }); + const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id }); + + const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' }) as any; + const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' }) as any; + + expect(normalView.roles.map((role: any) => role.id)).toEqual([visibleRole.id, forcedRole.id]); + expect(normalView.badgeRoles.map((role: any) => role.id)).toEqual([visibleRole.id, forcedRole.id]); + expect(normalView.badgeRoles.every((role: any) => typeof role.id === 'string')).toBe(true); + expect(moderatorView.roles.map((role: any) => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]); + expect(moderatorView.badgeRoles.map((role: any) => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]); + }); + test('alsoKnownAs as string does not throw', async () => { const me = await createUser(); const who = await createUser(); From f88e647a83bf1da9179bd4e074509cfce834d786 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Thu, 21 May 2026 03:47:22 +0900 Subject: [PATCH 05/31] =?UTF-8?q?docs:=20=E3=83=AD=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E8=A8=AD=E5=AE=9A=E3=81=AECHANGELOG=E3=82=92?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f026e3f689d..c6f91f2c536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +### General + +### Client +- Feat: 公開ロールとロールバッジを個別に非表示にできるようにしました + +### Server +- Feat: 公開ロール/ロールバッジを常に表示する管理者設定を追加しました + + ## 2026.5.2 ### Note From 63a8520191e79ad539d8160635edc0e3da8fe537 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sat, 23 May 2026 20:23:56 +0900 Subject: [PATCH 06/31] =?UTF-8?q?fix(backend):=20=E9=9D=9E=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=AE=E3=83=90=E3=83=83?= =?UTF-8?q?=E3=82=B8=E3=82=92=E8=BF=94=E3=81=95=E3=81=AA=E3=81=84=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/core/entities/UserEntityService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 60ab5528d1d..9ed45a16e97 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -547,7 +547,7 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得 - badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, iAmModerator, true) + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, false, true) .sort((a, b) => b.displayOrder - a.displayOrder) .map((r) => ({ id: r.id, From d490936bf6d79fff004c069c364f9c0941cd3a62 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sat, 23 May 2026 20:24:06 +0900 Subject: [PATCH 07/31] =?UTF-8?q?fix(frontend):=20=E9=9D=9E=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=AE=E5=B8=B8=E6=99=82?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E8=A8=AD=E5=AE=9A=E3=82=92=E9=9A=A0=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + packages/frontend/src/pages/admin/roles.editor.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f91f2c536..90d54a5a533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Client - Feat: 公開ロールとロールバッジを個別に非表示にできるようにしました +- Fix: ロール設定画面で公開ロールが無効な場合は常に表示する設定を表示しないようにしました ### Server - Feat: 公開ロール/ロールバッジを常に表示する管理者設定を追加しました diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 0c6ba8213c7..94261865e96 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + From e57e88ebc686ad642f0f712209959147f5e7376f Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sat, 23 May 2026 20:24:16 +0900 Subject: [PATCH 08/31] =?UTF-8?q?feat(frontend):=20=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E8=A8=AD=E5=AE=9A=E3=83=9A=E3=83=BC=E3=82=B8=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/ja-JP.yml | 1 + .../frontend/src/pages/settings/index.vue | 5 + .../frontend/src/pages/settings/other.vue | 12 --- .../frontend/src/pages/settings/privacy.vue | 102 ------------------ .../frontend/src/pages/settings/roles.vue | 100 +++++++++++++++++ packages/frontend/src/router.definition.ts | 4 + packages/i18n/src/autogen/locale.ts | 4 + 7 files changed, 114 insertions(+), 114 deletions(-) create mode 100644 packages/frontend/src/pages/settings/roles.vue diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 845bc38cd1f..6848b0ef6eb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1094,6 +1094,7 @@ likeOnlyForRemote: "全て (リモートはいいねのみ)" nonSensitiveOnly: "非センシティブのみ" nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)" rolesAssignedToMe: "自分に割り当てられたロール" +roleSettings: "ロール設定" resetPasswordConfirm: "パスワードリセットしますか?" sensitiveWords: "センシティブワード" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index abfac372758..9a1f1291f37 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -93,6 +93,11 @@ const menuDef = computed(() => [{ text: i18n.ts.privacy, to: '/settings/privacy', active: currentPage.value?.route.name === 'privacy', + }, { + icon: 'ti ti-badges', + text: i18n.ts.roleSettings, + to: '/settings/roles', + active: currentPage.value?.route.name === 'roles', }, { icon: 'ti ti-bell', text: i18n.ts.notifications, diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 4facc696a42..1213569b6a3 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -49,17 +49,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - -
- -
-
-
- @@ -171,7 +160,6 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import FormSection from '@/components/form/section.vue'; import { prefer } from '@/preferences.js'; -import MkRolePreview from '@/components/MkRolePreview.vue'; import { signout } from '@/signout.js'; import { migrateOldSettings } from '@/pref-migrate.js'; import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js'; diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 0d8ac678a9c..edc71e51563 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -51,41 +51,6 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - -
- {{ i18n.ts._roleDisplay.noRoles }} - - - - -
-
-
- @@ -248,7 +213,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue new file mode 100644 index 00000000000..6351f7b04aa --- /dev/null +++ b/packages/frontend/src/pages/settings/roles.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index d59c9d1c6ff..93fe1cd5227 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -88,6 +88,10 @@ export const ROUTE_DEF = [{ path: '/privacy', name: 'privacy', component: page(() => import('@/pages/settings/privacy.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('@/pages/settings/roles.vue')), }, { path: '/emoji-palette', name: 'emoji-palette', diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index aa921aba2c3..00f0d0f498b 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -4388,6 +4388,10 @@ export interface Locale extends ILocale { * 自分に割り当てられたロール */ "rolesAssignedToMe": string; + /** + * ロール設定 + */ + "roleSettings": string; /** * パスワードリセットしますか? */ From a4a3606435693fbfcab91b8bb185f6d0e4d5c53a Mon Sep 17 00:00:00 2001 From: mattyatea Date: Sat, 23 May 2026 21:01:38 +0900 Subject: [PATCH 09/31] fixup! 4263f62e7b5a83bd47af4d1afb6667a637b4cc53 Created by IntelliJ Git plugin for drop selected changes operation --- .../admin/roles/display-visibility.test.ts | 54 ------------------- .../src/server/api/endpoints/i/update.test.ts | 24 --------- 2 files changed, 78 deletions(-) delete mode 100644 packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts delete mode 100644 packages/backend/src/server/api/endpoints/i/update.test.ts diff --git a/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts b/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts deleted file mode 100644 index 83a172fb158..00000000000 --- a/packages/backend/src/server/api/endpoints/admin/roles/display-visibility.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { describe, test, expect } from 'vitest'; -import { getValidator } from '../../../../../../test/prelude/get-api-validator.js'; -import { paramDef as createParamDef } from './create.js'; -import { paramDef as updateParamDef } from './update.js'; - -const VALID = true; -const INVALID = false; - -const baseCreateParams = { - name: 'Role', - description: '', - color: null, - iconUrl: null, - target: 'manual', - condFormula: { - id: 'ebef1684672d49b6ad821b3ec3784f85', - type: 'isRemote', - }, - isPublic: true, - isModerator: false, - isAdministrator: false, - asBadge: false, - canEditMembersByModerator: false, - displayOrder: 0, - policies: {}, -} as const; - -describe('api:admin/roles display visibility validation', () => { - describe('create', () => { - const v = getValidator(createParamDef); - - test('Accept omitted isPublicDisplayRequired', () => expect(v({ ...baseCreateParams })).toBe(VALID)); - test('Accept false isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: false })).toBe(VALID)); - test('Accept true isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: true })).toBe(VALID)); - test('Reject non-boolean isPublicDisplayRequired', () => expect(v({ ...baseCreateParams, isPublicDisplayRequired: 'true' })).toBe(INVALID)); - }); - - describe('update', () => { - const v = getValidator(updateParamDef); - const baseUpdateParams = { roleId: '9m4e2mr0ui' } as const; - - test('Accept omitted isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams })).toBe(VALID)); - test('Accept false isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: false })).toBe(VALID)); - test('Accept true isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: true })).toBe(VALID)); - test('Reject non-boolean isPublicDisplayRequired', () => expect(v({ ...baseUpdateParams, isPublicDisplayRequired: 'true' })).toBe(INVALID)); - }); -}); diff --git a/packages/backend/src/server/api/endpoints/i/update.test.ts b/packages/backend/src/server/api/endpoints/i/update.test.ts deleted file mode 100644 index ee426f03046..00000000000 --- a/packages/backend/src/server/api/endpoints/i/update.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -process.env.NODE_ENV = 'test'; - -import { describe, test, expect } from 'vitest'; -import { getValidator } from '../../../../../test/prelude/get-api-validator.js'; -import { paramDef } from './update.js'; - -const VALID = true; -const INVALID = false; - -describe('api:i/update', () => { - describe('validation', () => { - const v = getValidator(paramDef); - - test('Accept hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['9m4e2mr0ui'] })).toBe(VALID)); - test('Reject malformed hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['not-valid:id'] })).toBe(INVALID)); - test('Reject too many hiddenRoleIds', () => expect(v({ hiddenRoleIds: Array.from({ length: 257 }, (_, i) => `role${i}`) })).toBe(INVALID)); - test('Reject duplicate hiddenRoleIds', () => expect(v({ hiddenRoleIds: ['9m4e2mr0ui', '9m4e2mr0ui'] })).toBe(INVALID)); - }); -}); From 2debf09bf247cdca5d727c4851af0bef4017cb21 Mon Sep 17 00:00:00 2001 From: mattyatea Date: Mon, 29 Jun 2026 22:23:26 +0900 Subject: [PATCH 10/31] Tighten user entity test types --- .agents/skills/add-api-endpoint/SKILL.md | 253 ++++++++++++++++++ .agents/skills/add-i18n-key/SKILL.md | 117 ++++++++ .agents/skills/add-mk-component/SKILL.md | 174 ++++++++++++ .agents/skills/context-budget/SKILL.md | 148 ++++++++++ .agents/skills/create-migration/SKILL.md | 156 +++++++++++ .../source-command-harness-audit/SKILL.md | 152 +++++++++++ .../source-command-quality-gate/SKILL.md | 128 +++++++++ .codex/agents/misskey-api-reviewer.toml | 164 ++++++++++++ .codex/agents/vue-component-reviewer.toml | 173 ++++++++++++ .serena/.gitignore | 2 + .serena/project.yml | 133 +++++++++ packages/backend/test/e2e/users.ts | 12 +- .../test/unit/entities/UserEntityService.ts | 24 +- 13 files changed, 1620 insertions(+), 16 deletions(-) create mode 100644 .agents/skills/add-api-endpoint/SKILL.md create mode 100644 .agents/skills/add-i18n-key/SKILL.md create mode 100644 .agents/skills/add-mk-component/SKILL.md create mode 100644 .agents/skills/context-budget/SKILL.md create mode 100644 .agents/skills/create-migration/SKILL.md create mode 100644 .agents/skills/source-command-harness-audit/SKILL.md create mode 100644 .agents/skills/source-command-quality-gate/SKILL.md create mode 100644 .codex/agents/misskey-api-reviewer.toml create mode 100644 .codex/agents/vue-component-reviewer.toml create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml diff --git a/.agents/skills/add-api-endpoint/SKILL.md b/.agents/skills/add-api-endpoint/SKILL.md new file mode 100644 index 00000000000..7fa1d8eeea7 --- /dev/null +++ b/.agents/skills/add-api-endpoint/SKILL.md @@ -0,0 +1,253 @@ +--- +name: add-api-endpoint +description: Misskey の REST API エンドポイント (/api//) を NestJS DI + meta/paramDef 規約で追加する。バックエンドに新しい API ルートを足す時に必ず使う。endpoint-list.ts への手動登録、e2e テスト、misskey-js 再生成、CHANGELOG までの一連の手順を含む。 +--- + +# Misskey API エンドポイント追加スキル + +`packages/backend/src/server/api/endpoints//.ts` に新規エンドポイントを追加するためのワークフロー。**手順 4 (endpoint-list.ts 登録) を忘れると 404 になる** 点に最大の注意を払う。 + +## 最重要事実 (見落とすと壊れる) + +1. エンドポイントは **glob 自動収集されない**。[packages/backend/src/server/api/endpoint-list.ts](../../../packages/backend/src/server/api/endpoint-list.ts) への 1 行追加が必須。 +2. `meta` / `paramDef` を変えたら **misskey-js の再生成が必須**。`pnpm build-misskey-js-with-types` を忘れると CI の `check-misskey-js-autogen` で必ず落ちる。 +3. `meta.errors` の各 `id` は **UUID**。重複させない (既存全 UUID と衝突確認)。 + +## ステップ 1: ファイル配置と SPDX + +`packages/backend/src/server/api/endpoints//.ts` に新規作成する。`` は機能領域 (例: `notes`, `users`, `admin/announcements`)。 + +冒頭に SPDX ヘッダーを必ず付ける: + +```ts +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +``` + +## ステップ 2: 最小テンプレート (シンプル read 系) + +[endpoints/ping.ts](../../../packages/backend/src/server/api/endpoints/ping.ts) をベースに書く。認証不要・パラメータなし・小さなレスポンスの例: + +```ts +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; + +export const meta = { + tags: [''], + requireCredential: false, + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + // ... + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + ) { + super(meta, paramDef, async (ps, me) => { + // 実装 + }); + } +} +``` + +## ステップ 3: 認証付き / DI / errors を含むテンプレート + +[endpoints/notes/create.ts](../../../packages/backend/src/server/api/endpoints/notes/create.ts) を参照する。要点: + +```ts +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { DI } from '@/di-symbols.js'; +// import ms from 'ms'; // limit.duration に ms('1hour') 等を渡すとき (default import) + +export const meta = { + tags: ['notes'], + requireCredential: true, // 認証必須なら true + prohibitMoved: false, // moved user を拒否するか + kind: 'write:notes', // OAuth scope (requireCredential 時に必須) + limit: { + duration: 3600000, // ms('1hour') + max: 300, + }, + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx', // ★ UUID v4 を必ず生成 (`x`=hex, `y`=8/9/a/b)。下の「UUID 生成」を参照 + }, + }, + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', // packed entity に揃える場合 + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.notesRepository.findOneBy({ id: ps.noteId }); + if (note == null) throw new ApiError(meta.errors.noSuchNote); + // 実装 + }); + } +} +``` + +### meta フィールド早見表 + +| フィールド | 用途 | +|---|---| +| `tags` | OpenAPI タグ (機能領域) | +| `requireCredential` | 認証必須か | +| `requireModerator` / `requireAdmin` | 権限制限 | +| `prohibitMoved` | アカウント移行済ユーザーを拒否 | +| `kind` | OAuth scope (`read:notes` / `write:notes` 等)。`requireCredential: true` 時必須 | +| `limit` | レート制限 (`{ duration, max, key?, minInterval? }`) | +| `errors` | エラー定義。各要素に `message` / `code` / `id` (UUID v4) 必須 | +| `res` | JSON Schema or `ref: ''` (packed entity 参照) | +| `requireFile` | ファイルアップロード必須 | +| `secure` | secure cookie 必要 | +| `allowGet` | GET メソッド許可 | +| `cacheSec` | レスポンスキャッシュ秒数 | +| `description` | OpenAPI 説明 | + +詳細は [endpoints.ts](../../../packages/backend/src/server/api/endpoints.ts) の型定義 (lines 11-125) を参照。 + +### paramDef の特殊フォーマット + +JSON Schema (AJV) ベースだが、Misskey 拡張を使える: + +- `format: 'misskey:id'` — ID 文字列バリデーション +- `allOf` / `anyOf` / `oneOf` — 複合条件 +- `default` — デフォルト値 + +詳細は [endpoint-base.ts](../../../packages/backend/src/server/api/endpoint-base.ts) を参照。 + +### エラー throw + +**「公開 API エラーとして API クライアントに返したいもの」は必ず `throw new ApiError(meta.errors.)` を使う**。`meta.errors` に列挙した上で `ApiError` でラップしないと、misskey-js 側の型情報に出ず、レスポンスも 500 になる。第 2 引数で追加情報を渡せる: + +```ts +throw new ApiError(meta.errors.invalidParam, { reason: 'too short' }); +``` + +一方で、**想定外の例外 (DB 不整合 / 下層サービスの bug など) を握り潰すために `try/catch` で `ApiError` に変換するのは避ける**。既存 endpoint も「期待される業務エラーは `ApiError` に変換し、それ以外は `throw err;` で再 throw する」という二段構えになっている。`packages/backend/src/server/api/endpoints/notes/create.ts` の `catch` 節 (末尾の `throw err;`) を参照。生の `throw` を全面禁止すると未知例外も 200 で潰れて debug が困難になるので、このバランスを保つ。 + +詳細は [error.ts](../../../packages/backend/src/server/api/error.ts) の `ApiError` クラスを参照。 + +### UUID 生成 + +```bash +node -e "console.log(crypto.randomUUID())" +``` + +その UUID が他のエンドポイントの `id` と衝突していないか必ず確認: + +```bash +grep -r "id: '<生成した UUID>'" packages/backend/src/server/api/endpoints/ +``` + +## ステップ 4: ★必須 — endpoint-list.ts に登録 + +[packages/backend/src/server/api/endpoint-list.ts](../../../packages/backend/src/server/api/endpoint-list.ts) の同カテゴリ末尾に 1 行追加する(既存の並びを崩さない): + +```ts +export * as '/' from './endpoints//.js'; +``` + +ファイル冒頭のコメント (`When you add new endpoint, you should add it to this file.`) の通り、このリストが API ルーティングの単一の真実。**忘れると 404**。 + +`EndpointsModule.ts` がこのファイルの全エクスポートを `Object.entries()` で反復し、NestJS provider (`provide: 'ep:'`) を生成する。 + +## ステップ 5: e2e テスト追加 + +[packages/backend/test/e2e/endpoints.ts](../../../packages/backend/test/e2e/endpoints.ts) に対応する `describe` / `test` を追加する。`api()` ヘルパーで叩く: + +```ts +describe('/', () => { + test('正常系', async () => { + const res = await api('/', { /* params */ }, alice); + assert.strictEqual(res.status, 200); + }); +}); +``` + +実行: `pnpm --filter backend test:e2e` + +## ステップ 6: misskey-js 再生成 (★必須) + +`meta` / `paramDef` / `res` を変えたら必ず実行する: + +```bash +pnpm build-misskey-js-with-types +``` + +これで以下が更新される: + +- `packages/backend/built/api.json` (OpenAPI spec) +- `packages/misskey-js/generator/api.json` +- `packages/misskey-js/src/autogen/*.ts` (TypeScript 型) + +PR に `packages/misskey-js/src/autogen/` 配下の差分が含まれていないと、CI の `check-misskey-js-autogen` で落ちる。 + +## ステップ 7: Lint と typecheck + +```bash +pnpm --filter backend lint +``` + +(typecheck = `tsgo --noEmit` / ESLint = `eslint`) + +## ステップ 8: CHANGELOG + +ユーザー影響がある (新機能 / 既存挙動変更) なら、`CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加する ([AGENTS.md §CHANGELOG](../../../AGENTS.md#changelog) 参照): + +``` +- Feat: /api// を追加 +``` + +純粋なリファクタや内部用なら不要。 + +## 参照ファイル + +- [endpoints.ts (meta/paramDef 型定義)](../../../packages/backend/src/server/api/endpoints.ts) +- [endpoint-base.ts (Endpoint 基底クラス)](../../../packages/backend/src/server/api/endpoint-base.ts) +- [endpoint-list.ts (★ ここに登録)](../../../packages/backend/src/server/api/endpoint-list.ts) +- [error.ts (ApiError)](../../../packages/backend/src/server/api/error.ts) +- [endpoints/ping.ts (最小例)](../../../packages/backend/src/server/api/endpoints/ping.ts) +- [endpoints/notes/create.ts (DI + errors の典型)](../../../packages/backend/src/server/api/endpoints/notes/create.ts) +- [test/e2e/endpoints.ts (テスト例)](../../../packages/backend/test/e2e/endpoints.ts) +- [scripts/generate_api_json.js (misskey-js 生成元)](../../../packages/backend/scripts/generate_api_json.js) diff --git a/.agents/skills/add-i18n-key/SKILL.md b/.agents/skills/add-i18n-key/SKILL.md new file mode 100644 index 00000000000..899c61826de --- /dev/null +++ b/.agents/skills/add-i18n-key/SKILL.md @@ -0,0 +1,117 @@ +--- +name: add-i18n-key +description: Misskey の i18n キーを追加・修正する。locales/ja-JP.yml のみ編集可能で、他言語ファイル (en-US.yml 等 39 言語) は Crowdin の自動配信先のため絶対に触らない。型は packages/i18n が ja-JP.yml から自動再生成する。frontend からは i18n.ts. または i18n.tsx.(...) で参照する。 +--- + +# Misskey i18n キー追加スキル + +UI 文言の追加・変更を行う際の規約。**手動編集して良いのは `locales/ja-JP.yml` のみ。** + +## 大前提 (絶対 NG) + +- **`locales/.yml` (ja-JP.yml 以外) の編集は禁止**。これらは Crowdin の自動配信先で、手動編集すると次の同期で上書き喪失する ([locales/README.md](../../../locales/README.md), [crowdin.yml](../../../crowdin.yml))。 +- 文字列リテラルを SFC に直書きしない (`こんにちは` 等)。必ず `i18n.ts.` を経由する。 +- 既存キーの破壊的リネームは Crowdin 翻訳資産も道連れになるので慎重に。追加・改名併用 (新キー追加 → 移行 → 旧キー削除) を検討する。 + +## ステップ 1: ja-JP.yml にキーを追加 + +[locales/ja-JP.yml](../../../locales/ja-JP.yml) を編集する。YAML の階層構造を維持し、関連するセクションに配置する: + +```yaml +# トップレベル単純キー +save: "保存" + +# ネストしたカテゴリ (アンダースコア接頭辞は内部カテゴリ) +_settings: + general: "全般" + appearance: "外観" + +# パラメータ付き (単純なプレースホルダ置換) +# ICU MessageFormat の plural / select / number / date などは非対応 +# 使えるのは `{name}` のような単純な置換のみ +greeting: "こんにちは、{name}さん" +``` + +### 命名のお作法 + +- 単純キー: lowerCamelCase (例: `saveChanges`, `confirmDelete`)。 +- カテゴリ: アンダースコア接頭辞 (例: `_settings`, `_abuseUserReport`)。 +- 既存セクション内に置く場合はアルファベット順を維持する (新セクション全体を末尾に追加するのは可)。 + +## ステップ 2: 型定義の自動再生成 + +`packages/i18n/build.ts` が `ja-JP.yml` を解析し、TypeScript インターフェースを [packages/i18n/src/autogen/locale.ts](../../../packages/i18n/src/autogen/locale.ts) に出力する。 + +### 自動 (推奨) + +`pnpm dev` 実行中なら、`packages/i18n` の watch スクリプトが yml の変更を検知して自動再生成する。 + +### 手動 + +```bash +pnpm --filter i18n generate +``` + +実体は `tsx scripts/generateLocaleInterface.ts`。 + +### 失敗パターン + +これを実行せずに frontend 側で `i18n.ts.` を参照すると、`Locale` インターフェースに追加されていないため、typecheck で「Property '' does not exist on type 'Locale'」というエラーになる。`pnpm --filter frontend lint` で発覚する。 + +## ステップ 3: frontend での参照 + +```ts +import { i18n } from '@/i18n.js'; +``` + +| 用途 | 書き方 | +|---|---| +| 単純文字列 | `i18n.ts.save` | +| ネスト | `i18n.ts._settings.general` | +| パラメータ付き | `i18n.tsx.greeting({ name: userName })` | +| Vue テンプレート内 | `{{ i18n.ts.save }}` / `{{ i18n.tsx.greeting({ name }) }}` | + +`i18n.ts` は型付き文字列、`i18n.tsx` は MessageFormat 関数。 + +## ステップ 4: 検証 + +```bash +# i18n パッケージの型再生成 + typecheck +pnpm --filter i18n lint + +# frontend で新キー参照箇所の型チェック +pnpm --filter frontend lint +``` + +## 例: 「ノートを削除しますか?」確認ダイアログを追加する + +1. `locales/ja-JP.yml`: + ```yaml + _notes: + deleteConfirm: "このノートを削除しますか?" + ``` +2. `pnpm --filter i18n generate` (または `pnpm dev` で watch 中) +3. SFC: + ```vue + + ``` + +## 参照ファイル + +- [locales/README.md (★ 編集ポリシー根拠)](../../../locales/README.md) +- [locales/ja-JP.yml](../../../locales/ja-JP.yml) +- [packages/i18n/build.ts](../../../packages/i18n/build.ts) +- [packages/i18n/src/autogen/locale.ts (生成物)](../../../packages/i18n/src/autogen/locale.ts) +- [packages/frontend/src/i18n.ts](../../../packages/frontend/src/i18n.ts) diff --git a/.agents/skills/add-mk-component/SKILL.md b/.agents/skills/add-mk-component/SKILL.md new file mode 100644 index 00000000000..fd35c5dd3b2 --- /dev/null +++ b/.agents/skills/add-mk-component/SKILL.md @@ -0,0 +1,174 @@ +--- +name: add-mk-component +description: Misskey フロントエンドの新規 Vue 3 コンポーネントを追加する。Mk* 命名 / SPDX (HTML コメント) / + + +``` + +### 規約ポイント + +| 項目 | 規約 | +|---|---| +| ` +``` + +### `os` の主なヘルパー (詳細は [os.ts](../../../packages/frontend/src/os.ts)) + +| 関数 | 用途 | +|---|---| +| `os.alert({ type, title?, text })` | 単方向アラート | +| `os.confirm({ type, title, text })` | yes/no 確認 (`{ canceled }` を返す) | +| `os.toast(message)` | 一時通知 | +| `os.popup(component, props, handlers)` | 任意コンポーネントの非同期ポップアップ | +| `os.popupMenu(items, anchor?)` | コンテキストメニュー | +| `os.form(title, fields)` | フォームダイアログ | +| `os.apiWithDialog(endpoint, data)` | API 呼出し + エラー時ダイアログ表示 | + +## ステップ 5: Storybook ストーリー併設 + +[MkButton.stories.impl.ts](../../../packages/frontend/src/components/MkButton.stories.impl.ts) を雛形として参考にする。`.stories.impl.ts` も `packages/frontend/src/` 配下の `.ts` ファイルなので [AGENTS.md §1 SPDX ヘッダー必須](../../../AGENTS.md#1-spdx-ヘッダー必須) の対象であり、冒頭に SPDX ヘッダーを必ず付ける (HTML コメント形式ではなく `/* */` 形式)。形式 (以下の `MkXxx` は実際のコンポーネント名に置換する): + +```ts +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import type { StoryObj } from '@storybook/vue3'; +import MkXxx from './MkXxx.vue'; + +export const Default = { + render(args) { + return { + components: { MkXxx }, + setup() { + return { args }; + }, + template: 'slot content', + }; + }, + args: { + variant: 'primary', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +``` + +`Vue` SFC は default export なので、`import MkXxx from './MkXxx.vue';` のように名前付き import ではなく default import で書く。実行確認は `pnpm --filter frontend storybook-dev`。 + +## ステップ 6: Lint と typecheck + +```bash +pnpm --filter frontend lint +``` + +(typecheck = vue-tsc 等、ESLint = `@misskey-dev/eslint-plugin` 含む) + +ESLint --fix をピンポイントで: + +```bash +pnpm exec eslint --fix packages/frontend/src/components/Mk.vue +``` + +## ステップ 7: 既存コンポーネントとの整合性確認 + +- 似た用途の既存 `Mk*` コンポーネントを参考に、スタイルやプロップ命名を揃える。 +- `_button` / `_panel` / `_selectable` などの **共通 utility class** (グローバルスタイルにある) を活用できるか確認する。 +- 大きな機能なら、Storybook stories で各バリエーションを網羅する。 + +## 参照ファイル + +- [MkInfo.vue (シンプル例)](../../../packages/frontend/src/components/MkInfo.vue) +- [MkButton.vue (汎用ボタン例)](../../../packages/frontend/src/components/MkButton.vue) +- [MkInput.vue (generics + 多機能例)](../../../packages/frontend/src/components/MkInput.vue) +- [MkButton.stories.impl.ts (Storybook 雛形)](../../../packages/frontend/src/components/MkButton.stories.impl.ts) +- [packages/frontend/src/os.ts](../../../packages/frontend/src/os.ts) +- [packages/frontend/src/i18n.ts](../../../packages/frontend/src/i18n.ts) diff --git a/.agents/skills/context-budget/SKILL.md b/.agents/skills/context-budget/SKILL.md new file mode 100644 index 00000000000..97bc3ea444a --- /dev/null +++ b/.agents/skills/context-budget/SKILL.md @@ -0,0 +1,148 @@ +--- +name: context-budget +description: Codex セッションのコンテキスト窓消費を agents/skills/MCP/rules/AGENTS.md ごとに見える化し、肥大化と冗長コンポーネントを検出して節約候補を提示する。"コンテキスト消費を見せて"、"context budget"、"context audit"、"トークン内訳"、"これ以上 MCP 入る?" 等の発話で起動する。 +--- + + + +# Context Budget + +セッション内に読み込まれるコンポーネント (agents / skills / rules / MCP servers / AGENTS.md) の token overhead を分析し、空き context を回復する具体策を提示する。 + +## 使う場面 + +- セッションが重い・出力品質が落ちてきた感覚がある +- 直近で skills / agents / MCP server を多数追加した +- 残りの context headroom を知りたい +- 追加コンポーネントを入れる前に空きを確認したい +- 「context-budget」「token 内訳」等のキーワードでユーザーが明示的に要請した時 (Misskey リポジトリにはこの名前のスラッシュコマンドは登録していない — 本 skill は名前 / description マッチで auto-invoke される想定。実装済の slash command 一覧は [.Codex/commands/](../../commands/) を参照) + +## 仕組み + +### Phase 1: Inventory + +各コンポーネントを走査して token を推定する。 + +**Agents** (`.Codex/agents/*.md`) +- 行数とトークン数 (`words × 1.3`) を計算 +- frontmatter `description` の長さを抽出 +- フラグ: 200 行超 (重い)、description 30 word 超 (frontmatter 肥大) + +**Skills** (`.Codex/skills/*/SKILL.md`) +- SKILL.md ごとに token を計算 +- フラグ: 400 行超 +- `.agents/skills/` 等の重複コピーは除外 + +**Rules** (リポジトリルートの `AGENTS.md` + `.Codex/` から `@-import` されるファイル) +- ファイル単位で token 計算 +- フラグ: 100 行超 +- 同一言語モジュール内の内容重複を検出 + +**MCP Servers** (`.mcp.json` または有効 MCP 設定) +- server 数と総 tool 数 +- schema overhead をツールあたり ~500 token で見積もる +- フラグ: 20 tool 超のサーバー、`gh` / `git` / `npm` 等の CLI を単純ラップしただけのサーバー + +**AGENTS.md** (project + user-level) +- ファイルごとに token を計算 +- フラグ: 合計 300 行超 + +### Phase 2: Classify + +| バケット | 判定基準 | 行動 | +|--------------------|-------------------------------------------------------------|-----------------------------------| +| **Always needed** | AGENTS.md から参照されている / 有効コマンドの裏 / 現プロジェクトと一致 | 維持 | +| **Sometimes needed** | ドメイン依存 (例: 言語パターン)、AGENTS.md 参照なし | オンデマンド有効化を検討 | +| **Rarely needed** | コマンド参照なし、内容重複、明確な用途なし | 削除または lazy-load | + +### Phase 3: Detect Issues + +- **Bloated agent description** — frontmatter description が 30 word 超だと、Task ツール起動のたびに毎回ロードされる +- **Heavy agents** — 200 行超は Task ツールの context を毎回膨らませる +- **Redundant components** — agent ロジックを重複する skill、AGENTS.md と重複する rule +- **MCP over-subscription** — 10 server 超、または CLI 代用可能なサーバー +- **AGENTS.md bloat** — 冗長説明、古いセクション、rule に移すべき指示 + +### Phase 4: Report + +``` +Context Budget Report +═══════════════════════════════════════ + +Total estimated overhead: ~XX,XXX tokens +Context model: <現在モデル名> (K window) ← 例: Codex Opus 4.7 (1M), Codex Sonnet (200K) +Effective available context: ~XXX,XXX tokens (XX%) + +Component Breakdown: +┌─────────────────┬────────┬───────────┐ +│ Component │ Count │ Tokens │ +├─────────────────┼────────┼───────────┤ +│ Agents │ N │ ~X,XXX │ +│ Skills │ N │ ~X,XXX │ +│ Rules │ N │ ~X,XXX │ +│ MCP tools │ N │ ~XX,XXX │ +│ AGENTS.md │ N │ ~X,XXX │ +└─────────────────┴────────┴───────────┘ + +WARNING: Issues Found (N): +[token 節約量の降順] + +Top 3 Optimizations: +1. [action] → save ~X,XXX tokens +2. [action] → save ~X,XXX tokens +3. [action] → save ~X,XXX tokens + +Potential savings: ~XX,XXX tokens (XX% of current overhead) +``` + +verbose mode ではさらにファイルごとの token 内訳、最重ファイルの行単位ブレークダウン、重複行の対比、MCP tool 一覧 + tool ごとの schema サイズ推定を出す。 + +## 例 + +**基本監査** +``` +User: コンテキスト消費を見せて +Skill: 16 agents (12,400 tokens), 28 skills (6,200), 87 MCP tools (43,500), 2 AGENTS.md (1,200) + Flags: 重い agent 3 個、CLI 代用可能な MCP 3 個 + Top saving: MCP 3 個削除 → -27,500 tokens (overhead の 47% 削減) +``` + +**Verbose** +``` +User: トークン内訳をファイル単位で +Skill: 上記レポートに加えて、planner.md (213 lines, 1,840 tokens) のような + per-file 行内訳、MCP tool ごとのサイズ、rule の重複行を side-by-side で表示 +``` + +**追加前チェック** +``` +User: MCP server を 5 個追加したいが、空きある? +Skill: 現状 33% → 5 server (≈ 50 tools) 追加で +25,000 tokens → 45% に到達 + 推奨: CLI 代用可能な server 2 個を先に外して 40% 以下を維持 +``` + +## ベストプラクティス + +- **トークン推定**: prose は `words × 1.3`、code 主体は `chars / 4` +- **MCP は最大のレバー**: tool あたり ~500 token、30-tool server ひとつで全 skill より大きい +- **agent description は常時ロード**: 呼ばれない agent でも description は毎 Task 投入 +- **verbose は debug 用**: 普段は使わない +- **変更後は監査**: agent/skill/MCP 追加直後に走らせて creep を早期発見 + +## Misskey 固有メモ + +- Misskey は MCP server をプロジェクトで明示登録していないため (`.mcp.json` 不在)、現状 overhead の支配項は AGENTS.md と公式プラグイン群の skills / agents description である。 +- ECC プラグインがユーザースコープで `installed_plugins.json` に存在するため、プロジェクトで `enabledPlugins` に追加していなくても system reminder に 200+ skill が現れる。これらは description が短いので個別 overhead は小さいが、合計値の確認に本 skill を使う。 diff --git a/.agents/skills/create-migration/SKILL.md b/.agents/skills/create-migration/SKILL.md new file mode 100644 index 00000000000..64f76cb9d71 --- /dev/null +++ b/.agents/skills/create-migration/SKILL.md @@ -0,0 +1,156 @@ +--- +name: create-migration +description: Misskey の TypeORM マイグレーションを公式 CLI (migration:generate / migration:create) で正しく生成し、SPDX ヘッダー付与・up/down 整合・check-migrations 確認まで誘導する。エンティティのスキーマ変更を含むあらゆる DB 変更、または手書き SQL によるデータ移行が必要な時に使用する。 +--- + +# Misskey マイグレーション作成スキル + +`packages/backend/migration/` に新規 TypeORM マイグレーションを追加するためのワークフロー。 + +## 大前提 (絶対 NG) + +- **既にマージ済み (develop / master) のマイグレーションファイルを編集しない** ([AGENTS.md §3](../../../AGENTS.md#3-マージ済み-migration-を絶対に編集しない))。本番履歴の改変は深刻なデータ不整合を引き起こす。スキーマ変更は **常に新しいタイムスタンプで新規ファイル** を作る。 +- ファイル名のタイムスタンプ部分を後から書き換えない (順序が壊れる)。 + +> 作り方は AGENTS.md §3 の「`Date.now()` で UNIX ms を取得 → `{ms}-{PascalName}.js` を手書き」が最低ライン。エンティティ差分から自動生成したい (= TypeORM の `migration:generate` を使う) 場合は本 skill の手順に従う。**どちらでも構わない**が、エンティティ変更を伴う時は CLI 経由のほうが取り漏れが減るので推奨。 + +## ステップ 1: どちらの方式を使うか決める + +| 状況 | 方式 | +|---|---| +| エンティティ (`packages/backend/src/models/*.ts`) を `@Column` / `@Index` / `@Entity` 等で先に変更し、差分から自動生成したい | `typeorm migration:generate` (本 skill の手順) | +| 手書き SQL / データ移行 / `CREATE INDEX CONCURRENTLY` など、エンティティ差分では表現できない変更 | `typeorm migration:create` で空雛形を作るか、`migrate-new` command で手書き雛形を作る | +| 列追加 1 本のような小規模変更で、既存ファイルをコピーした方が速い | AGENTS.md §3 の手順 (`Date.now()` + 手書き) でよい | + +迷ったら **まずエンティティを変更 → `migration:generate`** が原則。既存 342 ファイルのほぼすべてが `queryRunner.query(\`SQL...\`)` の raw SQL なので、CLI 出力でも手書きでもスタイルは揃う。 + +## ステップ 2: CLI 実行 + +ルートディレクトリから以下を実行する。`` は変更内容を表す PascalCase (例: `AddBirthdayIndex`, `AddCategoryToAvatarDecorations`)。 + +### 2-A. エンティティ差分から生成 + +[CONTRIBUTING.md §Migration作成方法](../../../CONTRIBUTING.md#migration作成方法) に記載の基本形: + +```bash +# packages/backend ディレクトリで実行する場合 (CONTRIBUTING.md 記載形式) +pnpm dlx typeorm migration:generate -d ormconfig.js -o --esm +``` + +**リポジトリルートから実行する場合** (AI が使う推奨形式。`pnpm --filter backend exec` を使うと backend の TypeORM バージョンと一致するため確実): + +```bash +pnpm --filter backend exec typeorm migration:generate -d ormconfig.js -o --esm migration/ +``` + +> **`--esm` について**: `-o` / `--outputJs` は「TS ではなく JS を出力する」オプション、`--esm` は「ESM 形式 (`export class ...`) で出力する」オプション。Misskey の既存 migration はすべて ESM JS であるため **両方が必須**。`--esm` を省略すると CommonJS 形式の JS が生成されスタイルが揃わない。 + +事前準備: + +- `pnpm build-pre` を実行して `built/meta.json` を生成する (`loadConfig()` が `built/meta.json` を必須とするため。`pnpm build` 済みであれば不要)。 +- `.config/default.yml` が存在すること (なければ `.config/example.yml` を参考に作成する)。 +- `pnpm --filter backend compile-config` を実行して `built/.config.json` を生成する (`ormconfig.js` が `loadConfig()` 経由で必須とする。未実行だと "Compiled configuration file not found." エラーになる)。 +- `pnpm --filter backend build` でエンティティを最新ビルド (CLI は `built/` を読む)。 +- ローカル DB を起動する (`docker compose -f compose.local-db.yml up -d`)。 + +### 2-B. 空の手書きマイグレーション + +```bash +pnpm --filter backend exec typeorm migration:create -o --esm migration/ +``` + +ローカル DB の起動とビルドは不要。空の `up` / `down` だけが生成される。 + +> `-o --esm` を **必ず付ける**。これが無いと `-.ts` (CommonJS / TS 出力) が生成されるが、Misskey の `ormconfig.js` は `migration/*.js` だけを読み、既存の他 migration も全て `export class ... { async up(queryRunner) {...} }` の ESM JS 形式なので、後で手作業で `.ts → .js` リネーム + `import { MigrationInterface }` 削除 + `class ... implements MigrationInterface` 削除をしないと走らない。`-o --esm` を付ければそのまま `.js` ESM で出るので、後処理は SPDX ヘッダー付与 (ステップ 3) だけで済む。 + +## ステップ 3: SPDX ヘッダー付与 + +CLI 出力には SPDX ヘッダーが含まれない。**必ず冒頭に追加する** (CI の `spdx` ジョブが失敗するため)。 + +```js +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +``` + +## ステップ 4: up / down の整合確認 + +- `up()` の各ステートメントに対し、`down()` で完全に巻き戻せること。 +- 列追加 (`ADD COLUMN`) ↔ 列削除 (`DROP COLUMN`)、テーブル作成 ↔ テーブル削除、 + FK 追加 ↔ FK 削除、インデックス作成 ↔ インデックス削除 を必ずペアで書く。 +- `down()` を空のまま残さない。本番ロールバック時に詰む。 + +### インデックス追加時の注意 (CREATE INDEX CONCURRENTLY) + +大規模テーブルへの `CREATE INDEX` は本番で長時間ロックする恐れがある。`CONCURRENTLY` で発行するときは **migration 側にも対応が必要**: PostgreSQL は `CREATE INDEX CONCURRENTLY` を transaction 内で実行できないため、migration class に以下を仕込んで TypeORM に「この migration は transaction を張らない」と指示する。 + +参照実装: [packages/backend/migration/1745378064470-composite-note-index.js](../../../packages/backend/migration/1745378064470-composite-note-index.js)。 + +```js +const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; + +export class CompositeNoteIndex1745378064470 { + name = 'CompositeNoteIndex1745378064470'; + transaction = isConcurrentIndexMigrationEnabled ? false : undefined; + + async up(queryRunner) { + const concurrently = isConcurrentIndexMigrationEnabled; + if (concurrently) { + // CREATE INDEX CONCURRENTLY ... + } else { + // CREATE INDEX ... + } + } + + async down(queryRunner) { + // 同様に環境変数で分岐 + } +} +``` + +要点: + +- **`transaction = isConcurrentIndexMigrationEnabled ? false : undefined;`** が必須。これがないと `CREATE INDEX CONCURRENTLY` が transaction 内で実行されて `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block` で失敗する。 +- 環境変数 `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY=1` がデフォルト OFF。OFF のときは普通の `CREATE INDEX` (transaction 内) で動く必要がある。`up`/`down` 双方を環境変数で分岐させる。 +- `ormconfig.js` の `migrationsTransactionMode` は **環境変数で切り替わる**: `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY=1` のときだけ `'each'` (各 migration が個別 transaction)、未設定時は `'all'` (全 migration を 1 つの transaction でラップ) ([ormconfig.js:19](../../../packages/backend/ormconfig.js#L19))。普段は `'all'` 前提なので、CONCURRENTLY を使う migration を書く時だけこのフラグの存在を意識すれば良い。 + +### 関連エンティティとの一致 + +`migration:generate` を使った場合、エンティティ側の `@Column` / `@Entity` 修正と DB スキーマが食い違うとビルド全体がズレる。生成後に該当エンティティと SQL の対応を目視確認すること。 + +## ステップ 5: 検証 + +ルートから実行: + +```bash +# 未反映の差分が無いか (新規 migration が生成すべき DDL を取り逃していないか) +pnpm --filter backend check-migrations + +# ローカル DB に適用 +pnpm migrate + +# ロールバック (down が壊れていないか) +pnpm revert + +# 再適用 (順方向にもう一度通す) +pnpm migrate +``` + +`check-migrations` の実体は [scripts/check_migrations_clean.js](../../../packages/backend/scripts/check_migrations_clean.js)。TypeORM の `dataSource.driver.createSchemaBuilder().log()` で pending DDL を取得し、`upQueries` / `downQueries` のいずれかが残っていれば非ゼロ終了する。**順序検査ではなく**「エンティティと migration が同期しているか」の検査。 + +## ステップ 6: 既存ファイル参照テンプレ + +新規ファイルを書くときは、変更パターンが近い既存ファイルを **必ずひとつ開いて並べて書く**。スタイルが激しくズレた PR は差し戻されやすい。 + +| パターン | 参照ファイル | +|---|---| +| インデックス追加 + 関数定義 | [packages/backend/migration/1767169026317-birthday-index.js](../../../packages/backend/migration/1767169026317-birthday-index.js) | +| 列追加のみ | [packages/backend/migration/1766652173085-add-category-to-avatar-decorations.js](../../../packages/backend/migration/1766652173085-add-category-to-avatar-decorations.js) | +| テーブル新規作成 + FK | [packages/backend/migration/1761569941833-add-channel-muting.js](../../../packages/backend/migration/1761569941833-add-channel-muting.js) | + +クラス命名規則は **PascalCase 名 + 13 桁タイムスタンプ** (例: `class BirthdayIndex1767169026317`)。`name` プロパティもクラス名と同一文字列にする。 + +## ステップ 7: CHANGELOG (ユーザー影響がある場合) + +スキーマ変更がユーザーに見える挙動を生む場合のみ、`CHANGELOG.md` の `## Unreleased` → `### Server` または `### General` に 1 行追加する ([AGENTS.md §CHANGELOG](../../../AGENTS.md#changelog) 参照)。内部リファクタや純粋なインデックス追加は不要。 diff --git a/.agents/skills/source-command-harness-audit/SKILL.md b/.agents/skills/source-command-harness-audit/SKILL.md new file mode 100644 index 00000000000..d96c286ee17 --- /dev/null +++ b/.agents/skills/source-command-harness-audit/SKILL.md @@ -0,0 +1,152 @@ +--- +name: "source-command-harness-audit" +description: "Misskey の .Codex/ ハーネス (skills/agents/commands) を 7 カテゴリで採点する確定的な監査。" +--- + +# source-command-harness-audit + +Use this skill when the user asks to run the migrated source command `harness-audit`. + +## Command Template + + + +# /harness-audit — Misskey ハーネス監査 + +Misskey リポジトリの `.Codex/` 構成を 7 カテゴリで採点し、改善優先度を提示する。 + +## Usage + +`/harness-audit [scope]` + +- `scope` (任意): `repo` (default) / `skills` / `commands` / `agents` + +## 評価カテゴリ (各 0-10) + +| # | カテゴリ | 評価軸 | +| --- | --- | --- | +| 1 | Tool Coverage | skill / agent / command の数、欠けているワークフロー段、重複なし | +| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、AGENTS.md の肥大化 | +| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck | +| 4 | Memory Persistence | docs/* の同期状態を評価。プロジェクト側 `.Codex/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する | +| 5 | Eval Coverage | testing.md の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド | +| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 | +| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし | + +## Misskey 固有の確認項目 (採点根拠コマンド) + +採点時に以下を実コマンドで確認する。各項目の **属するカテゴリ** は項目内に明記する (#1-#3 は Security Guardrails、#4 は Tool Coverage、#5 は Quality Gates): + +```bash +# 1. [Security Guardrails] SPDX 適用率 (新規ファイル想定の汎用チェック) +# - node_modules を prune で除外 +# - packages/misskey-js は MIT サブパッケージなので AGPL ヘッダーを持たない (AGENTS.md §1) → 除外 +# - built/ なども除外 +# 候補にはなお *.config.{ts,js} / *eslint* / *.d.ts のような CI 上 SPDX 対象外 +# (.github/workflows/check-spdx-license-id.yml の exclude 参照) も混ざるため、 +# 上位に出たファイルが「新規追加した実コード」かどうかは目視判定する。 +find packages \ + \( -type d \( -name node_modules -o -name built -o -name dist -o -path 'packages/misskey-js' \) -prune \) \ + -o -type f \( -name '*.ts' -o -name '*.js' -o -name '*.vue' -o -name '*.scss' \) -print \ + | xargs -r grep -L 'SPDX-License-Identifier: AGPL-3.0-only' | head -20 +# → 上位に新規実コードが無ければ満点 + +# 2. [Security Guardrails] ja-JP.yml 以外の locales が直近で手動編集されていないか +# --pretty=format: でコミットヘッダ行を抑止し、ファイル名行のみを残してから grep する。 +# Crowdin の自動同期 commit でも他言語 yml は更新されるため、出力が 0 行になることは少ない。 +# 出力があった場合は、author / commit message を確認し Crowdin 由来か手動編集かを判定する: +# git log --since='30 days ago' --pretty=format:'%h %an %s' -- locales/.yml +git log --since='30 days ago' --pretty=format: --name-only -- 'locales/*.yml' \ + | grep -v '^$' | grep -v 'ja-JP.yml' | sort -u +# → 出力が無い、または全て Crowdin 由来 commit なら満点 + +# 3. [Security Guardrails] migration の pending DDL 検査 (TypeORM schema builder) +pnpm --filter backend check-migrations +# → 0 errors (= "All migrations are clean.") なら満点 + +# 4. [Tool Coverage] endpoint-list.ts 登録漏れ (新規 endpoint がリストにない場合) +# endpoints/ は再帰構造 (notes/create.ts, admin/announcements/create.ts 等) で 400+ ファイルあるため、 +# endpoint-list.ts も `export * as '/' from './endpoints//.js';` 形式で +# 1 ファイル 1 行登録される。両者の行数を「再帰 .ts 数」と「export * as 行数」で比較する。 +# e2e / 単体テストは endpoint ではないので *.test.ts を除外する。 +endpoint_files=$(find packages/backend/src/server/api/endpoints -type f -name '*.ts' ! -name '*.test.ts' | wc -l) +list_entries=$(grep -cE "^export \* as " packages/backend/src/server/api/endpoint-list.ts) +echo "endpoints (recursive): $endpoint_files / endpoint-list.ts entries: $list_entries" +# 差分が 0 なら満点。差分が出たら、登録漏れの具体特定: +comm -23 \ + <(find packages/backend/src/server/api/endpoints -type f -name '*.ts' ! -name '*.test.ts' \ + | sed -E 's|.*/endpoints/||;s|\.ts$||' | sort -u) \ + <(grep -oE "^export \* as '[^']+'" packages/backend/src/server/api/endpoint-list.ts \ + | sed -E "s/^export \* as '([^']+)'/\1/" | sort -u) +# 出力された行が登録漏れの endpoint。0 行なら満点。 + +# 5. [Quality Gates] console.log の混入 +grep -rn 'console\.\(log\|debug\)' packages/backend/src packages/frontend/src 2>/dev/null \ + | grep -v 'node_modules\|test\|.spec\.\|.test\.' | wc -l +# → 0 が理想 +``` + +## 出力契約 + +以下を返す: + +1. `overall_score` / `max_score` (repo は 70 点満点) +2. カテゴリごとのスコア + 具体的な根拠 +3. 失敗チェック項目と該当ファイルパス +4. Top 3 改善アクション +5. 次に適用を推奨する skill / 手順 + +## サンプル出力 + +```text +Harness Audit (repo): 55/70 + +Tool Coverage: 9/10 (skills 5, agents 2, commands 5 — 偏りなし) +Context Efficiency: 8/10 (description 平均 3-5 行、肥大なし) +Quality Gates: 5/10 (Stop hook 共有設定に未登録 / `/quality-gate` あり) +Memory Persistence: 5/10 (プロジェクト側 memory/ 未採用方針 = 既定値) +Eval Coverage: 7/10 (testing.md 網羅、Storybook 一部抜け) +Security Guardrails: 10/10 (SPDX 100%, locales OK, migrations clean) +Cost Efficiency: 8/10 (context-budget 導入済 / MCP 0) + +Failed Checks: +- packages/frontend/src/.../X.vue で SPDX 欠落 (Security Guardrails) +- console.log が backend に 3 件 (Quality Gates) +- 共有 Stop hook なし (Quality Gates) — 各 contributor が `.Codex/settings.local.json` で opt-in する方針なら減点しなくて良い + +Top 3 Actions: +1) [Security Guardrails] SPDX 欠落 1 ファイルを修正: + packages/frontend/src/.../X.vue +2) [Quality Gates] backend の console.log 3 件を logger に置換。 + git grep "console\.log" packages/backend/src +3) [Cost Efficiency] enabledPlugins から未使用のものを外す。 + .Codex/docs/plugins.md と照合。 + +Suggested next skills to apply: +- /quality-gate で完了前に lint + unit test を回す +- context-budget で plugin 由来の overhead を確認 +``` + +## 採点の信頼性 + +- 確定的: 同じ commit / 同じ `.Codex/` 構成なら同じスコア +- ヒューリスティクス: 「description の冗長度」のような主観項目は同一基準で機械的に判定 +- スクリプト不要: `pnpm` と `git`、`grep`/`find` 等の標準ツールのみ + +## 参考: ECC オリジナルとの差分 + +- ECC 版は `node scripts/harness-audit.js` を直叩きする運用で、ECC リポジトリ全体に閉じた採点だった。 +- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、`pnpm` ベースの実コマンドで根拠を取る方式に再設計。 +- 結果として ECC への依存はゼロ。 diff --git a/.agents/skills/source-command-quality-gate/SKILL.md b/.agents/skills/source-command-quality-gate/SKILL.md new file mode 100644 index 00000000000..577e66c1d5c --- /dev/null +++ b/.agents/skills/source-command-quality-gate/SKILL.md @@ -0,0 +1,128 @@ +--- +name: "source-command-quality-gate" +description: "Misskey の lint / typecheck / 高速テストを順に実行して品質ゲートを通すコマンド。完了前の軽量検証用。" +--- + +# source-command-quality-gate + +Use this skill when the user asks to run the migrated source command `quality-gate`. + +## Command Template + + + +# /quality-gate — Misskey 軽量品質ゲート + +`/quality-gate [scope]` + +完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。 + +## Scope + +- `repo` (default) — 全パッケージ +- `backend` — `packages/backend` のみ +- `frontend` — `packages/frontend` のみ +- `path/to/file.ts` — 単一ファイルへの ESLint --fix のみ + +## Pipeline + +### Repo scope (全部) + +各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json)) で、ルートの `pnpm lint` は `pnpm --no-bail -r lint` (= 全パッケージで lint を `--no-bail` で実行)。**typecheck は lint に含まれている**ため、通常はこの 2 コマンドで十分: + +```bash +# 1. Lint (= typecheck + ESLint、全パッケージ。--no-bail で最初の失敗で止まらず全結果を集める) +pnpm lint + +# 2. Unit test (高速、e2e は含まない) +pnpm --filter backend test +pnpm --filter frontend test +``` + +#### 詳細を分けて見たい時のみ (optional) + +lint がまとめて失敗していて typecheck の結果だけ単独で見たい場合は、以下を個別に回す。**通常は不要** (lint の出力を読めば足りる): + +```bash +pnpm --filter backend typecheck # tsgo 単体 +pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るため) +``` + +### Backend scope + +`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。軽量ゲートでは typecheck の二重実行を避けるため `lint` + `test` のみ: + +```bash +pnpm --filter backend lint +pnpm --filter backend test +``` + +`tsgo` の出力を単独で見たい時のみ optional で `pnpm --filter backend typecheck` を別途回す。 + +### Frontend scope + +`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、軽量ゲートでは Backend 同様に `lint` + `test` のみ: + +```bash +pnpm --filter frontend lint +pnpm --filter frontend test +``` + +`vue-tsc` の出力を単独で見たい時のみ optional で `pnpm --filter frontend typecheck` を別途回す。 + +### Single file scope + +```bash +pnpm exec eslint --fix +``` + +## Output + +実行したフェーズの pass/fail と件数を集計する。標準パイプラインは `pnpm lint` (typecheck 内包) と unit test のみなので、デフォルトの出力は以下のようになる: + +```text +Quality Gate (repo): + +Lint: PASS (0 errors, 2 warnings) +Backend ut: PASS (412/412) +Frontend ut: PASS (87/87) + +→ 完了前の軽量チェック OK。重い e2e / 連合テストは CI 側で実行される。 +``` + +`#### 詳細を分けて見たい時のみ (optional)` で個別 typecheck (`pnpm --filter backend typecheck` / `pnpm --filter frontend typecheck`) も回した場合のみ、その結果を追加行として表示する: + +```text +Quality Gate (repo): + +Lint: PASS (0 errors, 2 warnings) +Backend tc: PASS (0 errors) # optional 実行時のみ +Frontend tc: PASS (0 errors) # optional 実行時のみ +Backend ut: PASS (412/412) +Frontend ut: PASS (87/87) +``` + +失敗時は最初に落ちたフェーズで停止して詳細を見せる。 + +## 関連 skill / コマンド + +- `/check-misskey-js` コマンド — API 変更時の misskey-js 再生成 +- [AGENTS.md §必須コマンド](../../AGENTS.md#必須コマンド) — pnpm コマンド一覧の正典 + +## 元 ECC 版との差分 + +- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。 +- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。 +- e2e / federation / Cypress は重いため除外し CI 側に委譲。 diff --git a/.codex/agents/misskey-api-reviewer.toml b/.codex/agents/misskey-api-reviewer.toml new file mode 100644 index 00000000000..932c206440c --- /dev/null +++ b/.codex/agents/misskey-api-reviewer.toml @@ -0,0 +1,164 @@ +name = "misskey-api-reviewer" +description = "Misskey の API エンドポイント (packages/backend/src/server/api/endpoints/) の追加・変更を専門レビューする。SPDX / meta / paramDef / UUID 重複 / endpoint-list.ts 登録 / ApiError throw / misskey-js 再生成 / e2e / CHANGELOG を機械的にチェック。バックエンド API を追加・変更した PR レビューで呼び出す。" +developer_instructions = ''' +# Misskey API エンドポイントレビュアー + +Misskey バックエンド (`packages/backend`) の REST API エンドポイント追加・変更 PR を機械的にレビューする専門エージェント。規約の根拠は [.Codex/skills/add-api-endpoint/SKILL.md](../skills/add-api-endpoint/SKILL.md)。 + +## 役割 + +`packages/backend/src/server/api/endpoints/` 配下の `.ts` 変更を対象に、規約逸脱・登録漏れ・型自動生成漏れ・テスト不足を抽出する。良い点には触れず、改善が必要な箇所のみ報告する。 + +## レビュー対象の特定 + +呼び出し元から明示的にファイルが渡されたらそれを優先する。渡されなかった場合は **PR / ブランチ全体の差分** を取得する (未コミット差分のみではないことに注意)。 + +```bash +BASE=$(git merge-base origin/develop HEAD) +{ git diff --name-only "$BASE"...HEAD; git diff --name-only HEAD; git ls-files --others --exclude-standard; } \ + | sort -u \ + | grep -E '^packages/backend/src/server/api/endpoints/.*\.ts$' +``` + +`origin/develop` が無い環境では `develop` または `master` にフォールバックする。 + +加えて以下も同じ baseline で差分対象に含める: + +- `packages/backend/src/server/api/endpoint-list.ts` +- `packages/backend/test/e2e/**` (とくに `endpoints.ts` と `.ts`) +- `packages/misskey-js/src/autogen/**` +- `CHANGELOG.md` + +差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。 + +## チェックリスト + +### 1. SPDX ヘッダー (Critical) + +新規 `.ts` ファイル冒頭に以下があるか: + +``` +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +``` + +欠落すると CI の `spdx` ジョブが落ちる。 + +### 2. `meta` の必須・推奨フィールド (Major) + +[endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。 + +- `tags`: OpenAPI タグ (機能領域)。 +- `requireCredential`: 明示必須 (boolean)。 +- `kind`: OAuth scope。`requireCredential: true` のとき必須 (`read:account` / `write:notes` 等)。 +- `requireModerator` / `requireAdmin`: 権限制限が要るか。 +- `prohibitMoved`: 移行済アカウントを拒否するか (write 系で要検討)。 +- `limit`: レート制限 `{ duration, max, key?, minInterval? }`。書き込み系 / コスト高い処理で未指定なら指摘。 +- `errors`: エラー定義。各要素に `message` / `code` / `id` (UUID v4) が揃っているか。 +- `res`: JSON Schema または `ref: ''`。各プロパティに `optional` / `nullable` が **明示** されているか。 +- `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。 + +### 3. `meta.errors` の UUID 検証 (Critical) + +各 `errors[*].id` が: + +1. UUID v4 形式 (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`) か +2. 既存エンドポイントの `id` と重複していないか + +重複検査: + +```bash +grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoints/ +``` + +新規エンドポイントの全 `id` を抽出して衝突を確認する。 + +### 4. `paramDef` (Major) + +- JSON Schema 形式 (`type: 'object'`, `properties`, `required`) +- ID 文字列は `format: 'misskey:id'` +- `required` 配列で必須プロパティを明示 +- `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘) + +### 5. エンドポイント実装本体 (Major) + +- `Endpoint` を継承しているか。 +- `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。 +- DI は `@Inject(DI.xxx)` 形式か。 +- **クライアントに返すべき API エラーは `throw new ApiError(meta.errors.)`** ([error.ts](../../packages/backend/src/server/api/error.ts) 参照)。`meta.errors` で定義したエラーケースを `throw new Error(...)` で投げているなら指摘する。 +- 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト** は `throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。 +- 同期 `throw` は許容。非同期処理での例外伝搬を確認する。 + +### 6. ★ `endpoint-list.ts` への登録 (Critical) + +最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか: + +```ts +export * as '/' from './endpoints//.js'; +``` + +新規エンドポイントを抽出し、各々が `endpoint-list.ts` に存在するか grep で確認する: + +```bash +grep -F "'/'" packages/backend/src/server/api/endpoint-list.ts +``` + +**並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。 + +### 7. `misskey-js` 再生成 (Critical) + +`meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する: + +```bash +BASE=$(git merge-base origin/develop HEAD) +git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/ +``` + +差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ジョブで必ず落ちるため Critical 扱い。 + +### 8. e2e テスト (Major) + +[test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('/', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。 + +**describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`/` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。 + +### 9. CHANGELOG エントリ (Minor) + +ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加されているか確認する。 + +``` +- Feat: /api// を追加 +``` + +純粋な内部リファクタなら不要。 + +## 出力形式 + +優先度別に以下のフォーマットで出力する。 + +``` +## 🔴 Critical +- packages/backend/src/server/api/endpoints/foo/bar.ts:23 + meta.errors.fooError.id が UUID v4 形式ではない (実値: 'xxx-xxx')。 + `node -e "console.log(crypto.randomUUID())"` で再生成すること。 + +## 🟡 Major +- ... + +## 🔵 Minor +- ... +``` + +問題のないチェック項目には触れない。全項目クリアなら `✅ レビュー観点上の指摘なし` と短く返す。 + +## 参照 + +- [.Codex/skills/add-api-endpoint/SKILL.md](../skills/add-api-endpoint/SKILL.md) — 実装側の規約 (本エージェントの根拠) +- [endpoints.ts (meta/paramDef 型定義)](../../packages/backend/src/server/api/endpoints.ts) +- [endpoint-list.ts (★ 登録先)](../../packages/backend/src/server/api/endpoint-list.ts) +- [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts) +- [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts) +- [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) +- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)''' diff --git a/.codex/agents/vue-component-reviewer.toml b/.codex/agents/vue-component-reviewer.toml new file mode 100644 index 00000000000..910226d3c27 --- /dev/null +++ b/.codex/agents/vue-component-reviewer.toml @@ -0,0 +1,173 @@ +name = "vue-component-reviewer" +description = 'Misskey フロントエンド (packages/frontend/src/components/ / pages/) の Vue 3 SFC 変更を専門レビューする。SPDX (HTML コメント) / Mk* 命名 / - ``` - -## 参照ファイル - -- [locales/README.md (★ 編集ポリシー根拠)](../../../locales/README.md) -- [locales/ja-JP.yml](../../../locales/ja-JP.yml) -- [packages/i18n/build.ts](../../../packages/i18n/build.ts) -- [packages/i18n/src/autogen/locale.ts (生成物)](../../../packages/i18n/src/autogen/locale.ts) -- [packages/frontend/src/i18n.ts](../../../packages/frontend/src/i18n.ts) diff --git a/.agents/skills/add-mk-component/SKILL.md b/.agents/skills/add-mk-component/SKILL.md deleted file mode 100644 index fd35c5dd3b2..00000000000 --- a/.agents/skills/add-mk-component/SKILL.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: add-mk-component -description: Misskey フロントエンドの新規 Vue 3 コンポーネントを追加する。Mk* 命名 / SPDX (HTML コメント) / - - -``` - -### 規約ポイント - -| 項目 | 規約 | -|---|---| -| ` -``` - -### `os` の主なヘルパー (詳細は [os.ts](../../../packages/frontend/src/os.ts)) - -| 関数 | 用途 | -|---|---| -| `os.alert({ type, title?, text })` | 単方向アラート | -| `os.confirm({ type, title, text })` | yes/no 確認 (`{ canceled }` を返す) | -| `os.toast(message)` | 一時通知 | -| `os.popup(component, props, handlers)` | 任意コンポーネントの非同期ポップアップ | -| `os.popupMenu(items, anchor?)` | コンテキストメニュー | -| `os.form(title, fields)` | フォームダイアログ | -| `os.apiWithDialog(endpoint, data)` | API 呼出し + エラー時ダイアログ表示 | - -## ステップ 5: Storybook ストーリー併設 - -[MkButton.stories.impl.ts](../../../packages/frontend/src/components/MkButton.stories.impl.ts) を雛形として参考にする。`.stories.impl.ts` も `packages/frontend/src/` 配下の `.ts` ファイルなので [AGENTS.md §1 SPDX ヘッダー必須](../../../AGENTS.md#1-spdx-ヘッダー必須) の対象であり、冒頭に SPDX ヘッダーを必ず付ける (HTML コメント形式ではなく `/* */` 形式)。形式 (以下の `MkXxx` は実際のコンポーネント名に置換する): - -```ts -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; -import MkXxx from './MkXxx.vue'; - -export const Default = { - render(args) { - return { - components: { MkXxx }, - setup() { - return { args }; - }, - template: 'slot content', - }; - }, - args: { - variant: 'primary', - }, - parameters: { - layout: 'centered', - }, -} satisfies StoryObj; -``` - -`Vue` SFC は default export なので、`import MkXxx from './MkXxx.vue';` のように名前付き import ではなく default import で書く。実行確認は `pnpm --filter frontend storybook-dev`。 - -## ステップ 6: Lint と typecheck - -```bash -pnpm --filter frontend lint -``` - -(typecheck = vue-tsc 等、ESLint = `@misskey-dev/eslint-plugin` 含む) - -ESLint --fix をピンポイントで: - -```bash -pnpm exec eslint --fix packages/frontend/src/components/Mk.vue -``` - -## ステップ 7: 既存コンポーネントとの整合性確認 - -- 似た用途の既存 `Mk*` コンポーネントを参考に、スタイルやプロップ命名を揃える。 -- `_button` / `_panel` / `_selectable` などの **共通 utility class** (グローバルスタイルにある) を活用できるか確認する。 -- 大きな機能なら、Storybook stories で各バリエーションを網羅する。 - -## 参照ファイル - -- [MkInfo.vue (シンプル例)](../../../packages/frontend/src/components/MkInfo.vue) -- [MkButton.vue (汎用ボタン例)](../../../packages/frontend/src/components/MkButton.vue) -- [MkInput.vue (generics + 多機能例)](../../../packages/frontend/src/components/MkInput.vue) -- [MkButton.stories.impl.ts (Storybook 雛形)](../../../packages/frontend/src/components/MkButton.stories.impl.ts) -- [packages/frontend/src/os.ts](../../../packages/frontend/src/os.ts) -- [packages/frontend/src/i18n.ts](../../../packages/frontend/src/i18n.ts) diff --git a/.agents/skills/context-budget/SKILL.md b/.agents/skills/context-budget/SKILL.md deleted file mode 100644 index 97bc3ea444a..00000000000 --- a/.agents/skills/context-budget/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: context-budget -description: Codex セッションのコンテキスト窓消費を agents/skills/MCP/rules/AGENTS.md ごとに見える化し、肥大化と冗長コンポーネントを検出して節約候補を提示する。"コンテキスト消費を見せて"、"context budget"、"context audit"、"トークン内訳"、"これ以上 MCP 入る?" 等の発話で起動する。 ---- - - - -# Context Budget - -セッション内に読み込まれるコンポーネント (agents / skills / rules / MCP servers / AGENTS.md) の token overhead を分析し、空き context を回復する具体策を提示する。 - -## 使う場面 - -- セッションが重い・出力品質が落ちてきた感覚がある -- 直近で skills / agents / MCP server を多数追加した -- 残りの context headroom を知りたい -- 追加コンポーネントを入れる前に空きを確認したい -- 「context-budget」「token 内訳」等のキーワードでユーザーが明示的に要請した時 (Misskey リポジトリにはこの名前のスラッシュコマンドは登録していない — 本 skill は名前 / description マッチで auto-invoke される想定。実装済の slash command 一覧は [.Codex/commands/](../../commands/) を参照) - -## 仕組み - -### Phase 1: Inventory - -各コンポーネントを走査して token を推定する。 - -**Agents** (`.Codex/agents/*.md`) -- 行数とトークン数 (`words × 1.3`) を計算 -- frontmatter `description` の長さを抽出 -- フラグ: 200 行超 (重い)、description 30 word 超 (frontmatter 肥大) - -**Skills** (`.Codex/skills/*/SKILL.md`) -- SKILL.md ごとに token を計算 -- フラグ: 400 行超 -- `.agents/skills/` 等の重複コピーは除外 - -**Rules** (リポジトリルートの `AGENTS.md` + `.Codex/` から `@-import` されるファイル) -- ファイル単位で token 計算 -- フラグ: 100 行超 -- 同一言語モジュール内の内容重複を検出 - -**MCP Servers** (`.mcp.json` または有効 MCP 設定) -- server 数と総 tool 数 -- schema overhead をツールあたり ~500 token で見積もる -- フラグ: 20 tool 超のサーバー、`gh` / `git` / `npm` 等の CLI を単純ラップしただけのサーバー - -**AGENTS.md** (project + user-level) -- ファイルごとに token を計算 -- フラグ: 合計 300 行超 - -### Phase 2: Classify - -| バケット | 判定基準 | 行動 | -|--------------------|-------------------------------------------------------------|-----------------------------------| -| **Always needed** | AGENTS.md から参照されている / 有効コマンドの裏 / 現プロジェクトと一致 | 維持 | -| **Sometimes needed** | ドメイン依存 (例: 言語パターン)、AGENTS.md 参照なし | オンデマンド有効化を検討 | -| **Rarely needed** | コマンド参照なし、内容重複、明確な用途なし | 削除または lazy-load | - -### Phase 3: Detect Issues - -- **Bloated agent description** — frontmatter description が 30 word 超だと、Task ツール起動のたびに毎回ロードされる -- **Heavy agents** — 200 行超は Task ツールの context を毎回膨らませる -- **Redundant components** — agent ロジックを重複する skill、AGENTS.md と重複する rule -- **MCP over-subscription** — 10 server 超、または CLI 代用可能なサーバー -- **AGENTS.md bloat** — 冗長説明、古いセクション、rule に移すべき指示 - -### Phase 4: Report - -``` -Context Budget Report -═══════════════════════════════════════ - -Total estimated overhead: ~XX,XXX tokens -Context model: <現在モデル名> (K window) ← 例: Codex Opus 4.7 (1M), Codex Sonnet (200K) -Effective available context: ~XXX,XXX tokens (XX%) - -Component Breakdown: -┌─────────────────┬────────┬───────────┐ -│ Component │ Count │ Tokens │ -├─────────────────┼────────┼───────────┤ -│ Agents │ N │ ~X,XXX │ -│ Skills │ N │ ~X,XXX │ -│ Rules │ N │ ~X,XXX │ -│ MCP tools │ N │ ~XX,XXX │ -│ AGENTS.md │ N │ ~X,XXX │ -└─────────────────┴────────┴───────────┘ - -WARNING: Issues Found (N): -[token 節約量の降順] - -Top 3 Optimizations: -1. [action] → save ~X,XXX tokens -2. [action] → save ~X,XXX tokens -3. [action] → save ~X,XXX tokens - -Potential savings: ~XX,XXX tokens (XX% of current overhead) -``` - -verbose mode ではさらにファイルごとの token 内訳、最重ファイルの行単位ブレークダウン、重複行の対比、MCP tool 一覧 + tool ごとの schema サイズ推定を出す。 - -## 例 - -**基本監査** -``` -User: コンテキスト消費を見せて -Skill: 16 agents (12,400 tokens), 28 skills (6,200), 87 MCP tools (43,500), 2 AGENTS.md (1,200) - Flags: 重い agent 3 個、CLI 代用可能な MCP 3 個 - Top saving: MCP 3 個削除 → -27,500 tokens (overhead の 47% 削減) -``` - -**Verbose** -``` -User: トークン内訳をファイル単位で -Skill: 上記レポートに加えて、planner.md (213 lines, 1,840 tokens) のような - per-file 行内訳、MCP tool ごとのサイズ、rule の重複行を side-by-side で表示 -``` - -**追加前チェック** -``` -User: MCP server を 5 個追加したいが、空きある? -Skill: 現状 33% → 5 server (≈ 50 tools) 追加で +25,000 tokens → 45% に到達 - 推奨: CLI 代用可能な server 2 個を先に外して 40% 以下を維持 -``` - -## ベストプラクティス - -- **トークン推定**: prose は `words × 1.3`、code 主体は `chars / 4` -- **MCP は最大のレバー**: tool あたり ~500 token、30-tool server ひとつで全 skill より大きい -- **agent description は常時ロード**: 呼ばれない agent でも description は毎 Task 投入 -- **verbose は debug 用**: 普段は使わない -- **変更後は監査**: agent/skill/MCP 追加直後に走らせて creep を早期発見 - -## Misskey 固有メモ - -- Misskey は MCP server をプロジェクトで明示登録していないため (`.mcp.json` 不在)、現状 overhead の支配項は AGENTS.md と公式プラグイン群の skills / agents description である。 -- ECC プラグインがユーザースコープで `installed_plugins.json` に存在するため、プロジェクトで `enabledPlugins` に追加していなくても system reminder に 200+ skill が現れる。これらは description が短いので個別 overhead は小さいが、合計値の確認に本 skill を使う。 diff --git a/.agents/skills/create-migration/SKILL.md b/.agents/skills/create-migration/SKILL.md deleted file mode 100644 index 64f76cb9d71..00000000000 --- a/.agents/skills/create-migration/SKILL.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -name: create-migration -description: Misskey の TypeORM マイグレーションを公式 CLI (migration:generate / migration:create) で正しく生成し、SPDX ヘッダー付与・up/down 整合・check-migrations 確認まで誘導する。エンティティのスキーマ変更を含むあらゆる DB 変更、または手書き SQL によるデータ移行が必要な時に使用する。 ---- - -# Misskey マイグレーション作成スキル - -`packages/backend/migration/` に新規 TypeORM マイグレーションを追加するためのワークフロー。 - -## 大前提 (絶対 NG) - -- **既にマージ済み (develop / master) のマイグレーションファイルを編集しない** ([AGENTS.md §3](../../../AGENTS.md#3-マージ済み-migration-を絶対に編集しない))。本番履歴の改変は深刻なデータ不整合を引き起こす。スキーマ変更は **常に新しいタイムスタンプで新規ファイル** を作る。 -- ファイル名のタイムスタンプ部分を後から書き換えない (順序が壊れる)。 - -> 作り方は AGENTS.md §3 の「`Date.now()` で UNIX ms を取得 → `{ms}-{PascalName}.js` を手書き」が最低ライン。エンティティ差分から自動生成したい (= TypeORM の `migration:generate` を使う) 場合は本 skill の手順に従う。**どちらでも構わない**が、エンティティ変更を伴う時は CLI 経由のほうが取り漏れが減るので推奨。 - -## ステップ 1: どちらの方式を使うか決める - -| 状況 | 方式 | -|---|---| -| エンティティ (`packages/backend/src/models/*.ts`) を `@Column` / `@Index` / `@Entity` 等で先に変更し、差分から自動生成したい | `typeorm migration:generate` (本 skill の手順) | -| 手書き SQL / データ移行 / `CREATE INDEX CONCURRENTLY` など、エンティティ差分では表現できない変更 | `typeorm migration:create` で空雛形を作るか、`migrate-new` command で手書き雛形を作る | -| 列追加 1 本のような小規模変更で、既存ファイルをコピーした方が速い | AGENTS.md §3 の手順 (`Date.now()` + 手書き) でよい | - -迷ったら **まずエンティティを変更 → `migration:generate`** が原則。既存 342 ファイルのほぼすべてが `queryRunner.query(\`SQL...\`)` の raw SQL なので、CLI 出力でも手書きでもスタイルは揃う。 - -## ステップ 2: CLI 実行 - -ルートディレクトリから以下を実行する。`` は変更内容を表す PascalCase (例: `AddBirthdayIndex`, `AddCategoryToAvatarDecorations`)。 - -### 2-A. エンティティ差分から生成 - -[CONTRIBUTING.md §Migration作成方法](../../../CONTRIBUTING.md#migration作成方法) に記載の基本形: - -```bash -# packages/backend ディレクトリで実行する場合 (CONTRIBUTING.md 記載形式) -pnpm dlx typeorm migration:generate -d ormconfig.js -o --esm -``` - -**リポジトリルートから実行する場合** (AI が使う推奨形式。`pnpm --filter backend exec` を使うと backend の TypeORM バージョンと一致するため確実): - -```bash -pnpm --filter backend exec typeorm migration:generate -d ormconfig.js -o --esm migration/ -``` - -> **`--esm` について**: `-o` / `--outputJs` は「TS ではなく JS を出力する」オプション、`--esm` は「ESM 形式 (`export class ...`) で出力する」オプション。Misskey の既存 migration はすべて ESM JS であるため **両方が必須**。`--esm` を省略すると CommonJS 形式の JS が生成されスタイルが揃わない。 - -事前準備: - -- `pnpm build-pre` を実行して `built/meta.json` を生成する (`loadConfig()` が `built/meta.json` を必須とするため。`pnpm build` 済みであれば不要)。 -- `.config/default.yml` が存在すること (なければ `.config/example.yml` を参考に作成する)。 -- `pnpm --filter backend compile-config` を実行して `built/.config.json` を生成する (`ormconfig.js` が `loadConfig()` 経由で必須とする。未実行だと "Compiled configuration file not found." エラーになる)。 -- `pnpm --filter backend build` でエンティティを最新ビルド (CLI は `built/` を読む)。 -- ローカル DB を起動する (`docker compose -f compose.local-db.yml up -d`)。 - -### 2-B. 空の手書きマイグレーション - -```bash -pnpm --filter backend exec typeorm migration:create -o --esm migration/ -``` - -ローカル DB の起動とビルドは不要。空の `up` / `down` だけが生成される。 - -> `-o --esm` を **必ず付ける**。これが無いと `-.ts` (CommonJS / TS 出力) が生成されるが、Misskey の `ormconfig.js` は `migration/*.js` だけを読み、既存の他 migration も全て `export class ... { async up(queryRunner) {...} }` の ESM JS 形式なので、後で手作業で `.ts → .js` リネーム + `import { MigrationInterface }` 削除 + `class ... implements MigrationInterface` 削除をしないと走らない。`-o --esm` を付ければそのまま `.js` ESM で出るので、後処理は SPDX ヘッダー付与 (ステップ 3) だけで済む。 - -## ステップ 3: SPDX ヘッダー付与 - -CLI 出力には SPDX ヘッダーが含まれない。**必ず冒頭に追加する** (CI の `spdx` ジョブが失敗するため)。 - -```js -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ -``` - -## ステップ 4: up / down の整合確認 - -- `up()` の各ステートメントに対し、`down()` で完全に巻き戻せること。 -- 列追加 (`ADD COLUMN`) ↔ 列削除 (`DROP COLUMN`)、テーブル作成 ↔ テーブル削除、 - FK 追加 ↔ FK 削除、インデックス作成 ↔ インデックス削除 を必ずペアで書く。 -- `down()` を空のまま残さない。本番ロールバック時に詰む。 - -### インデックス追加時の注意 (CREATE INDEX CONCURRENTLY) - -大規模テーブルへの `CREATE INDEX` は本番で長時間ロックする恐れがある。`CONCURRENTLY` で発行するときは **migration 側にも対応が必要**: PostgreSQL は `CREATE INDEX CONCURRENTLY` を transaction 内で実行できないため、migration class に以下を仕込んで TypeORM に「この migration は transaction を張らない」と指示する。 - -参照実装: [packages/backend/migration/1745378064470-composite-note-index.js](../../../packages/backend/migration/1745378064470-composite-note-index.js)。 - -```js -const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; - -export class CompositeNoteIndex1745378064470 { - name = 'CompositeNoteIndex1745378064470'; - transaction = isConcurrentIndexMigrationEnabled ? false : undefined; - - async up(queryRunner) { - const concurrently = isConcurrentIndexMigrationEnabled; - if (concurrently) { - // CREATE INDEX CONCURRENTLY ... - } else { - // CREATE INDEX ... - } - } - - async down(queryRunner) { - // 同様に環境変数で分岐 - } -} -``` - -要点: - -- **`transaction = isConcurrentIndexMigrationEnabled ? false : undefined;`** が必須。これがないと `CREATE INDEX CONCURRENTLY` が transaction 内で実行されて `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block` で失敗する。 -- 環境変数 `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY=1` がデフォルト OFF。OFF のときは普通の `CREATE INDEX` (transaction 内) で動く必要がある。`up`/`down` 双方を環境変数で分岐させる。 -- `ormconfig.js` の `migrationsTransactionMode` は **環境変数で切り替わる**: `MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY=1` のときだけ `'each'` (各 migration が個別 transaction)、未設定時は `'all'` (全 migration を 1 つの transaction でラップ) ([ormconfig.js:19](../../../packages/backend/ormconfig.js#L19))。普段は `'all'` 前提なので、CONCURRENTLY を使う migration を書く時だけこのフラグの存在を意識すれば良い。 - -### 関連エンティティとの一致 - -`migration:generate` を使った場合、エンティティ側の `@Column` / `@Entity` 修正と DB スキーマが食い違うとビルド全体がズレる。生成後に該当エンティティと SQL の対応を目視確認すること。 - -## ステップ 5: 検証 - -ルートから実行: - -```bash -# 未反映の差分が無いか (新規 migration が生成すべき DDL を取り逃していないか) -pnpm --filter backend check-migrations - -# ローカル DB に適用 -pnpm migrate - -# ロールバック (down が壊れていないか) -pnpm revert - -# 再適用 (順方向にもう一度通す) -pnpm migrate -``` - -`check-migrations` の実体は [scripts/check_migrations_clean.js](../../../packages/backend/scripts/check_migrations_clean.js)。TypeORM の `dataSource.driver.createSchemaBuilder().log()` で pending DDL を取得し、`upQueries` / `downQueries` のいずれかが残っていれば非ゼロ終了する。**順序検査ではなく**「エンティティと migration が同期しているか」の検査。 - -## ステップ 6: 既存ファイル参照テンプレ - -新規ファイルを書くときは、変更パターンが近い既存ファイルを **必ずひとつ開いて並べて書く**。スタイルが激しくズレた PR は差し戻されやすい。 - -| パターン | 参照ファイル | -|---|---| -| インデックス追加 + 関数定義 | [packages/backend/migration/1767169026317-birthday-index.js](../../../packages/backend/migration/1767169026317-birthday-index.js) | -| 列追加のみ | [packages/backend/migration/1766652173085-add-category-to-avatar-decorations.js](../../../packages/backend/migration/1766652173085-add-category-to-avatar-decorations.js) | -| テーブル新規作成 + FK | [packages/backend/migration/1761569941833-add-channel-muting.js](../../../packages/backend/migration/1761569941833-add-channel-muting.js) | - -クラス命名規則は **PascalCase 名 + 13 桁タイムスタンプ** (例: `class BirthdayIndex1767169026317`)。`name` プロパティもクラス名と同一文字列にする。 - -## ステップ 7: CHANGELOG (ユーザー影響がある場合) - -スキーマ変更がユーザーに見える挙動を生む場合のみ、`CHANGELOG.md` の `## Unreleased` → `### Server` または `### General` に 1 行追加する ([AGENTS.md §CHANGELOG](../../../AGENTS.md#changelog) 参照)。内部リファクタや純粋なインデックス追加は不要。 diff --git a/.agents/skills/source-command-harness-audit/SKILL.md b/.agents/skills/source-command-harness-audit/SKILL.md deleted file mode 100644 index d96c286ee17..00000000000 --- a/.agents/skills/source-command-harness-audit/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: "source-command-harness-audit" -description: "Misskey の .Codex/ ハーネス (skills/agents/commands) を 7 カテゴリで採点する確定的な監査。" ---- - -# source-command-harness-audit - -Use this skill when the user asks to run the migrated source command `harness-audit`. - -## Command Template - - - -# /harness-audit — Misskey ハーネス監査 - -Misskey リポジトリの `.Codex/` 構成を 7 カテゴリで採点し、改善優先度を提示する。 - -## Usage - -`/harness-audit [scope]` - -- `scope` (任意): `repo` (default) / `skills` / `commands` / `agents` - -## 評価カテゴリ (各 0-10) - -| # | カテゴリ | 評価軸 | -| --- | --- | --- | -| 1 | Tool Coverage | skill / agent / command の数、欠けているワークフロー段、重複なし | -| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、AGENTS.md の肥大化 | -| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck | -| 4 | Memory Persistence | docs/* の同期状態を評価。プロジェクト側 `.Codex/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する | -| 5 | Eval Coverage | testing.md の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド | -| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 | -| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし | - -## Misskey 固有の確認項目 (採点根拠コマンド) - -採点時に以下を実コマンドで確認する。各項目の **属するカテゴリ** は項目内に明記する (#1-#3 は Security Guardrails、#4 は Tool Coverage、#5 は Quality Gates): - -```bash -# 1. [Security Guardrails] SPDX 適用率 (新規ファイル想定の汎用チェック) -# - node_modules を prune で除外 -# - packages/misskey-js は MIT サブパッケージなので AGPL ヘッダーを持たない (AGENTS.md §1) → 除外 -# - built/ なども除外 -# 候補にはなお *.config.{ts,js} / *eslint* / *.d.ts のような CI 上 SPDX 対象外 -# (.github/workflows/check-spdx-license-id.yml の exclude 参照) も混ざるため、 -# 上位に出たファイルが「新規追加した実コード」かどうかは目視判定する。 -find packages \ - \( -type d \( -name node_modules -o -name built -o -name dist -o -path 'packages/misskey-js' \) -prune \) \ - -o -type f \( -name '*.ts' -o -name '*.js' -o -name '*.vue' -o -name '*.scss' \) -print \ - | xargs -r grep -L 'SPDX-License-Identifier: AGPL-3.0-only' | head -20 -# → 上位に新規実コードが無ければ満点 - -# 2. [Security Guardrails] ja-JP.yml 以外の locales が直近で手動編集されていないか -# --pretty=format: でコミットヘッダ行を抑止し、ファイル名行のみを残してから grep する。 -# Crowdin の自動同期 commit でも他言語 yml は更新されるため、出力が 0 行になることは少ない。 -# 出力があった場合は、author / commit message を確認し Crowdin 由来か手動編集かを判定する: -# git log --since='30 days ago' --pretty=format:'%h %an %s' -- locales/.yml -git log --since='30 days ago' --pretty=format: --name-only -- 'locales/*.yml' \ - | grep -v '^$' | grep -v 'ja-JP.yml' | sort -u -# → 出力が無い、または全て Crowdin 由来 commit なら満点 - -# 3. [Security Guardrails] migration の pending DDL 検査 (TypeORM schema builder) -pnpm --filter backend check-migrations -# → 0 errors (= "All migrations are clean.") なら満点 - -# 4. [Tool Coverage] endpoint-list.ts 登録漏れ (新規 endpoint がリストにない場合) -# endpoints/ は再帰構造 (notes/create.ts, admin/announcements/create.ts 等) で 400+ ファイルあるため、 -# endpoint-list.ts も `export * as '/' from './endpoints//.js';` 形式で -# 1 ファイル 1 行登録される。両者の行数を「再帰 .ts 数」と「export * as 行数」で比較する。 -# e2e / 単体テストは endpoint ではないので *.test.ts を除外する。 -endpoint_files=$(find packages/backend/src/server/api/endpoints -type f -name '*.ts' ! -name '*.test.ts' | wc -l) -list_entries=$(grep -cE "^export \* as " packages/backend/src/server/api/endpoint-list.ts) -echo "endpoints (recursive): $endpoint_files / endpoint-list.ts entries: $list_entries" -# 差分が 0 なら満点。差分が出たら、登録漏れの具体特定: -comm -23 \ - <(find packages/backend/src/server/api/endpoints -type f -name '*.ts' ! -name '*.test.ts' \ - | sed -E 's|.*/endpoints/||;s|\.ts$||' | sort -u) \ - <(grep -oE "^export \* as '[^']+'" packages/backend/src/server/api/endpoint-list.ts \ - | sed -E "s/^export \* as '([^']+)'/\1/" | sort -u) -# 出力された行が登録漏れの endpoint。0 行なら満点。 - -# 5. [Quality Gates] console.log の混入 -grep -rn 'console\.\(log\|debug\)' packages/backend/src packages/frontend/src 2>/dev/null \ - | grep -v 'node_modules\|test\|.spec\.\|.test\.' | wc -l -# → 0 が理想 -``` - -## 出力契約 - -以下を返す: - -1. `overall_score` / `max_score` (repo は 70 点満点) -2. カテゴリごとのスコア + 具体的な根拠 -3. 失敗チェック項目と該当ファイルパス -4. Top 3 改善アクション -5. 次に適用を推奨する skill / 手順 - -## サンプル出力 - -```text -Harness Audit (repo): 55/70 - -Tool Coverage: 9/10 (skills 5, agents 2, commands 5 — 偏りなし) -Context Efficiency: 8/10 (description 平均 3-5 行、肥大なし) -Quality Gates: 5/10 (Stop hook 共有設定に未登録 / `/quality-gate` あり) -Memory Persistence: 5/10 (プロジェクト側 memory/ 未採用方針 = 既定値) -Eval Coverage: 7/10 (testing.md 網羅、Storybook 一部抜け) -Security Guardrails: 10/10 (SPDX 100%, locales OK, migrations clean) -Cost Efficiency: 8/10 (context-budget 導入済 / MCP 0) - -Failed Checks: -- packages/frontend/src/.../X.vue で SPDX 欠落 (Security Guardrails) -- console.log が backend に 3 件 (Quality Gates) -- 共有 Stop hook なし (Quality Gates) — 各 contributor が `.Codex/settings.local.json` で opt-in する方針なら減点しなくて良い - -Top 3 Actions: -1) [Security Guardrails] SPDX 欠落 1 ファイルを修正: - packages/frontend/src/.../X.vue -2) [Quality Gates] backend の console.log 3 件を logger に置換。 - git grep "console\.log" packages/backend/src -3) [Cost Efficiency] enabledPlugins から未使用のものを外す。 - .Codex/docs/plugins.md と照合。 - -Suggested next skills to apply: -- /quality-gate で完了前に lint + unit test を回す -- context-budget で plugin 由来の overhead を確認 -``` - -## 採点の信頼性 - -- 確定的: 同じ commit / 同じ `.Codex/` 構成なら同じスコア -- ヒューリスティクス: 「description の冗長度」のような主観項目は同一基準で機械的に判定 -- スクリプト不要: `pnpm` と `git`、`grep`/`find` 等の標準ツールのみ - -## 参考: ECC オリジナルとの差分 - -- ECC 版は `node scripts/harness-audit.js` を直叩きする運用で、ECC リポジトリ全体に閉じた採点だった。 -- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、`pnpm` ベースの実コマンドで根拠を取る方式に再設計。 -- 結果として ECC への依存はゼロ。 diff --git a/.agents/skills/source-command-quality-gate/SKILL.md b/.agents/skills/source-command-quality-gate/SKILL.md deleted file mode 100644 index 577e66c1d5c..00000000000 --- a/.agents/skills/source-command-quality-gate/SKILL.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -name: "source-command-quality-gate" -description: "Misskey の lint / typecheck / 高速テストを順に実行して品質ゲートを通すコマンド。完了前の軽量検証用。" ---- - -# source-command-quality-gate - -Use this skill when the user asks to run the migrated source command `quality-gate`. - -## Command Template - - - -# /quality-gate — Misskey 軽量品質ゲート - -`/quality-gate [scope]` - -完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。 - -## Scope - -- `repo` (default) — 全パッケージ -- `backend` — `packages/backend` のみ -- `frontend` — `packages/frontend` のみ -- `path/to/file.ts` — 単一ファイルへの ESLint --fix のみ - -## Pipeline - -### Repo scope (全部) - -各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json)) で、ルートの `pnpm lint` は `pnpm --no-bail -r lint` (= 全パッケージで lint を `--no-bail` で実行)。**typecheck は lint に含まれている**ため、通常はこの 2 コマンドで十分: - -```bash -# 1. Lint (= typecheck + ESLint、全パッケージ。--no-bail で最初の失敗で止まらず全結果を集める) -pnpm lint - -# 2. Unit test (高速、e2e は含まない) -pnpm --filter backend test -pnpm --filter frontend test -``` - -#### 詳細を分けて見たい時のみ (optional) - -lint がまとめて失敗していて typecheck の結果だけ単独で見たい場合は、以下を個別に回す。**通常は不要** (lint の出力を読めば足りる): - -```bash -pnpm --filter backend typecheck # tsgo 単体 -pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るため) -``` - -### Backend scope - -`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。軽量ゲートでは typecheck の二重実行を避けるため `lint` + `test` のみ: - -```bash -pnpm --filter backend lint -pnpm --filter backend test -``` - -`tsgo` の出力を単独で見たい時のみ optional で `pnpm --filter backend typecheck` を別途回す。 - -### Frontend scope - -`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、軽量ゲートでは Backend 同様に `lint` + `test` のみ: - -```bash -pnpm --filter frontend lint -pnpm --filter frontend test -``` - -`vue-tsc` の出力を単独で見たい時のみ optional で `pnpm --filter frontend typecheck` を別途回す。 - -### Single file scope - -```bash -pnpm exec eslint --fix -``` - -## Output - -実行したフェーズの pass/fail と件数を集計する。標準パイプラインは `pnpm lint` (typecheck 内包) と unit test のみなので、デフォルトの出力は以下のようになる: - -```text -Quality Gate (repo): - -Lint: PASS (0 errors, 2 warnings) -Backend ut: PASS (412/412) -Frontend ut: PASS (87/87) - -→ 完了前の軽量チェック OK。重い e2e / 連合テストは CI 側で実行される。 -``` - -`#### 詳細を分けて見たい時のみ (optional)` で個別 typecheck (`pnpm --filter backend typecheck` / `pnpm --filter frontend typecheck`) も回した場合のみ、その結果を追加行として表示する: - -```text -Quality Gate (repo): - -Lint: PASS (0 errors, 2 warnings) -Backend tc: PASS (0 errors) # optional 実行時のみ -Frontend tc: PASS (0 errors) # optional 実行時のみ -Backend ut: PASS (412/412) -Frontend ut: PASS (87/87) -``` - -失敗時は最初に落ちたフェーズで停止して詳細を見せる。 - -## 関連 skill / コマンド - -- `/check-misskey-js` コマンド — API 変更時の misskey-js 再生成 -- [AGENTS.md §必須コマンド](../../AGENTS.md#必須コマンド) — pnpm コマンド一覧の正典 - -## 元 ECC 版との差分 - -- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。 -- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。 -- e2e / federation / Cypress は重いため除外し CI 側に委譲。 diff --git a/.codex/agents/misskey-api-reviewer.toml b/.codex/agents/misskey-api-reviewer.toml deleted file mode 100644 index 932c206440c..00000000000 --- a/.codex/agents/misskey-api-reviewer.toml +++ /dev/null @@ -1,164 +0,0 @@ -name = "misskey-api-reviewer" -description = "Misskey の API エンドポイント (packages/backend/src/server/api/endpoints/) の追加・変更を専門レビューする。SPDX / meta / paramDef / UUID 重複 / endpoint-list.ts 登録 / ApiError throw / misskey-js 再生成 / e2e / CHANGELOG を機械的にチェック。バックエンド API を追加・変更した PR レビューで呼び出す。" -developer_instructions = ''' -# Misskey API エンドポイントレビュアー - -Misskey バックエンド (`packages/backend`) の REST API エンドポイント追加・変更 PR を機械的にレビューする専門エージェント。規約の根拠は [.Codex/skills/add-api-endpoint/SKILL.md](../skills/add-api-endpoint/SKILL.md)。 - -## 役割 - -`packages/backend/src/server/api/endpoints/` 配下の `.ts` 変更を対象に、規約逸脱・登録漏れ・型自動生成漏れ・テスト不足を抽出する。良い点には触れず、改善が必要な箇所のみ報告する。 - -## レビュー対象の特定 - -呼び出し元から明示的にファイルが渡されたらそれを優先する。渡されなかった場合は **PR / ブランチ全体の差分** を取得する (未コミット差分のみではないことに注意)。 - -```bash -BASE=$(git merge-base origin/develop HEAD) -{ git diff --name-only "$BASE"...HEAD; git diff --name-only HEAD; git ls-files --others --exclude-standard; } \ - | sort -u \ - | grep -E '^packages/backend/src/server/api/endpoints/.*\.ts$' -``` - -`origin/develop` が無い環境では `develop` または `master` にフォールバックする。 - -加えて以下も同じ baseline で差分対象に含める: - -- `packages/backend/src/server/api/endpoint-list.ts` -- `packages/backend/test/e2e/**` (とくに `endpoints.ts` と `.ts`) -- `packages/misskey-js/src/autogen/**` -- `CHANGELOG.md` - -差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。 - -## チェックリスト - -### 1. SPDX ヘッダー (Critical) - -新規 `.ts` ファイル冒頭に以下があるか: - -``` -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ -``` - -欠落すると CI の `spdx` ジョブが落ちる。 - -### 2. `meta` の必須・推奨フィールド (Major) - -[endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。 - -- `tags`: OpenAPI タグ (機能領域)。 -- `requireCredential`: 明示必須 (boolean)。 -- `kind`: OAuth scope。`requireCredential: true` のとき必須 (`read:account` / `write:notes` 等)。 -- `requireModerator` / `requireAdmin`: 権限制限が要るか。 -- `prohibitMoved`: 移行済アカウントを拒否するか (write 系で要検討)。 -- `limit`: レート制限 `{ duration, max, key?, minInterval? }`。書き込み系 / コスト高い処理で未指定なら指摘。 -- `errors`: エラー定義。各要素に `message` / `code` / `id` (UUID v4) が揃っているか。 -- `res`: JSON Schema または `ref: ''`。各プロパティに `optional` / `nullable` が **明示** されているか。 -- `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。 - -### 3. `meta.errors` の UUID 検証 (Critical) - -各 `errors[*].id` が: - -1. UUID v4 形式 (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`) か -2. 既存エンドポイントの `id` と重複していないか - -重複検査: - -```bash -grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoints/ -``` - -新規エンドポイントの全 `id` を抽出して衝突を確認する。 - -### 4. `paramDef` (Major) - -- JSON Schema 形式 (`type: 'object'`, `properties`, `required`) -- ID 文字列は `format: 'misskey:id'` -- `required` 配列で必須プロパティを明示 -- `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘) - -### 5. エンドポイント実装本体 (Major) - -- `Endpoint` を継承しているか。 -- `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。 -- DI は `@Inject(DI.xxx)` 形式か。 -- **クライアントに返すべき API エラーは `throw new ApiError(meta.errors.)`** ([error.ts](../../packages/backend/src/server/api/error.ts) 参照)。`meta.errors` で定義したエラーケースを `throw new Error(...)` で投げているなら指摘する。 -- 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト** は `throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。 -- 同期 `throw` は許容。非同期処理での例外伝搬を確認する。 - -### 6. ★ `endpoint-list.ts` への登録 (Critical) - -最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか: - -```ts -export * as '/' from './endpoints//.js'; -``` - -新規エンドポイントを抽出し、各々が `endpoint-list.ts` に存在するか grep で確認する: - -```bash -grep -F "'/'" packages/backend/src/server/api/endpoint-list.ts -``` - -**並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。 - -### 7. `misskey-js` 再生成 (Critical) - -`meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する: - -```bash -BASE=$(git merge-base origin/develop HEAD) -git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/ -``` - -差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ジョブで必ず落ちるため Critical 扱い。 - -### 8. e2e テスト (Major) - -[test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('/', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。 - -**describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`/` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。 - -### 9. CHANGELOG エントリ (Minor) - -ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加されているか確認する。 - -``` -- Feat: /api// を追加 -``` - -純粋な内部リファクタなら不要。 - -## 出力形式 - -優先度別に以下のフォーマットで出力する。 - -``` -## 🔴 Critical -- packages/backend/src/server/api/endpoints/foo/bar.ts:23 - meta.errors.fooError.id が UUID v4 形式ではない (実値: 'xxx-xxx')。 - `node -e "console.log(crypto.randomUUID())"` で再生成すること。 - -## 🟡 Major -- ... - -## 🔵 Minor -- ... -``` - -問題のないチェック項目には触れない。全項目クリアなら `✅ レビュー観点上の指摘なし` と短く返す。 - -## 参照 - -- [.Codex/skills/add-api-endpoint/SKILL.md](../skills/add-api-endpoint/SKILL.md) — 実装側の規約 (本エージェントの根拠) -- [endpoints.ts (meta/paramDef 型定義)](../../packages/backend/src/server/api/endpoints.ts) -- [endpoint-list.ts (★ 登録先)](../../packages/backend/src/server/api/endpoint-list.ts) -- [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts) -- [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts) -- [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) -- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)''' diff --git a/.codex/agents/vue-component-reviewer.toml b/.codex/agents/vue-component-reviewer.toml deleted file mode 100644 index 910226d3c27..00000000000 --- a/.codex/agents/vue-component-reviewer.toml +++ /dev/null @@ -1,173 +0,0 @@ -name = "vue-component-reviewer" -description = 'Misskey フロントエンド (packages/frontend/src/components/ / pages/) の Vue 3 SFC 変更を専門レビューする。SPDX (HTML コメント) / Mk* 命名 / diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 6a9c829f773..556c5e1aec1 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -4404,10 +4404,6 @@ export interface Locale extends ILocale { * 自分に割り当てられたロール */ "rolesAssignedToMe": string; - /** - * ロール設定 - */ - "roleSettings": string; /** * パスワードリセットしますか? */ @@ -12185,21 +12181,17 @@ export interface Locale extends ILocale { }; "_roleDisplay": { /** - * 表示するロール/ロールバッジ + * 自分のプロフィールやノートに表示するロールを選択します。 */ - "title": string; + "description": string; /** - * 自分のプロフィールやノートに表示する公開ロールを選択します。 + * ロール/ロールバッジを表示する */ - "description": string; + "displayToggle": string; /** * 管理者により常に表示するよう設定されています。 */ "alwaysShownByAdmin": string; - /** - * 表示できる公開ロールはありません。 - */ - "noRoles": string; }; "_roleSelectDialog": { /** From 91db5ad709a033d4badbc0463b3a8c3667bf5dc0 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:21:46 +0900 Subject: [PATCH 19/31] fix --- locales/ja-JP.yml | 2 +- packages/i18n/src/autogen/locale.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 133d229f3dc..0619c79d48b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3248,7 +3248,7 @@ _gridComponent: _roleDisplay: description: "自分のプロフィールやノートに表示するロールを選択します。" displayToggle: "ロール/ロールバッジを表示する" - alwaysShownByAdmin: "管理者により常に表示するよう設定されています。" + alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。" _roleSelectDialog: notSelected: "選択されていません" diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 556c5e1aec1..ec8c619e439 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -12189,7 +12189,7 @@ export interface Locale extends ILocale { */ "displayToggle": string; /** - * 管理者により常に表示するよう設定されています。 + * 管理者の設定により非表示にすることはできません。 */ "alwaysShownByAdmin": string; }; From 17fa6cd13a332c9ce75241dee84a41bdfa69eedc Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:32:37 +0900 Subject: [PATCH 20/31] fix --- .../src/components/MkNoteDetailed.vue | 11 +- .../frontend/src/components/MkNoteHeader.vue | 11 +- .../src/pages/user/home.stories.impl.ts | 108 ++++-------------- 3 files changed, 36 insertions(+), 94 deletions(-) diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 6bd47ebae45..f8699e7b27f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +
+
@@ -344,6 +344,13 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT const conversation = ref([]); const replies = ref([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id); +const badgeRoles = computed(() => { + const roles = appearNote.user.badgeRoles; + if (roles == null || $i == null || $i.id !== props.note.userId) return roles; + + const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []); + return roles.filter(role => !hiddenRoleIds.has(role.id)); +}); useGlobalEvent('noteDeleted', (noteId) => { if (noteId === note.id || noteId === appearNote.id) { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 7c4f18ae8e1..4506fa4addd 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/user/home.stories.impl.ts b/packages/frontend/src/pages/user/home.stories.impl.ts index 088b3c108c2..66d35790411 100644 --- a/packages/frontend/src/pages/user/home.stories.impl.ts +++ b/packages/frontend/src/pages/user/home.stories.impl.ts @@ -3,82 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { expect, waitFor, within } from '@storybook/test'; +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../../.storybook/fakes.js'; import { commonHandlers } from '../../../.storybook/mocks.js'; import home_ from './home.vue'; -import type { StoryObj } from '@storybook/vue3'; -import { $i } from '@/i.js'; - -type UserDetailed = ReturnType; -type ProfileRole = UserDetailed['roles'][number] & { - isPublicDisplayRequired?: boolean; -}; -type MeRoleDisplay = NonNullable & { - hiddenRoleIds?: string[]; -}; - -function createProfileRole(id: string, name: string, color: string, isPublicDisplayRequired = false): ProfileRole { - return { - id, - name, - color, - iconUrl: null, - description: `${name} description`, - isModerator: false, - isAdministrator: false, - asBadge: false, - displayOrder: 0, - isPublicDisplayRequired, - }; -} - -const visibleRole = createProfileRole('role-display-visible', 'Visible Role', '#3b82f6'); -const hiddenRole = createProfileRole('role-display-hidden', 'Hidden Role', '#ef4444'); -const forcedRole = createProfileRole('role-display-forced', 'Forced Role', '#22c55e', true); - -const roleDisplayUser = { - ...userDetailed(), - roles: [visibleRole, hiddenRole, forcedRole], -}; - -function setStoryAccount(user: UserDetailed, hiddenRoleIds: string[]): void { - if ($i == null) return; - - Object.assign($i as MeRoleDisplay, { - id: user.id, - username: user.username, - host: user.host, - name: user.name, - hiddenRoleIds, - }); -} - -function renderHome(args: UserDetailedHomeArgs, hiddenRoleIds: string[] = []) { - return { - components: { - home_, - }, - setup() { - setStoryAccount(args.user, hiddenRoleIds); - - return { - props: args, - }; - }, - template: '', - }; -} - -type UserDetailedHomeArgs = { - user: UserDetailed; - disableNotes?: boolean; -}; - export const Default = { render(args) { - return renderHome(args); + return { + components: { + home_, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; }, args: { user: userDetailed(), @@ -129,21 +79,3 @@ export const Default = { }, }, } satisfies StoryObj; - -export const RoleDisplayVisibility = { - ...Default, - render(args) { - return renderHome(args, [hiddenRole.id]); - }, - async play({ canvasElement }) { - const canvas = within(canvasElement); - - await expect(await canvas.findByText(visibleRole.name)).toBeInTheDocument(); - await expect(await canvas.findByText(forcedRole.name)).toBeInTheDocument(); - await waitFor(() => expect(canvas.queryByText(hiddenRole.name)).not.toBeInTheDocument()); - }, - args: { - ...Default.args, - user: roleDisplayUser, - }, -} satisfies StoryObj; From dfc313cadb798172b83e60a0d9c1bcceec5412b8 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:08:06 +0900 Subject: [PATCH 21/31] refactor --- .../src/core/entities/RoleEntityService.ts | 27 +++++++++++++++++ .../src/core/entities/UserEntityService.ts | 30 +++++-------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index f4b6fdd44e1..b6b30080f47 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -84,4 +84,31 @@ export class RoleEntityService { ) { return Promise.all(roles.map(x => this.pack(x, me))); } + + @bindThis + public async packLite( + src: MiRole['id'] | MiRole, + ): Promise> { + const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); + + return { + id: role.id, + name: role.name, + color: role.color, + iconUrl: role.iconUrl, + description: role.description, + isModerator: role.isModerator, + isAdministrator: role.isAdministrator, + asBadge: role.asBadge, + isPublicDisplayRequired: role.isPublicDisplayRequired, + displayOrder: role.displayOrder, + }; + } + + @bindThis + public packLiteMany( + roles: (MiRole | MiRole['id'])[], + ) { + return Promise.all(roles.map(x => this.packLite(x))); + } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 60ab5528d1d..a27da9ab6c1 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -52,6 +52,7 @@ import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; +import type { RoleEntityService } from './RoleEntityService.js'; import { toArray } from '@/misc/prelude/array.js'; const Ajv = _Ajv.default; @@ -89,6 +90,7 @@ export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private pageEntityService: PageEntityService; + private roleEntityService: RoleEntityService; private customEmojiService: CustomEmojiService; private announcementService: AnnouncementService; private roleService: RoleService; @@ -145,6 +147,7 @@ export class UserEntityService implements OnModuleInit { this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); + this.roleEntityService = this.moduleRef.get('RoleEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.announcementService = this.moduleRef.get('AnnouncementService'); this.roleService = this.moduleRef.get('RoleService'); @@ -404,16 +407,9 @@ export class UserEntityService implements OnModuleInit { } @bindThis - private filterHiddenDisplayRoles>( - roles: T[], - user: MiUser, - iAmModerator: boolean, - shouldFilterHidden: boolean, - ): T[] { - if (iAmModerator || !shouldFilterHidden || user.hiddenRoleIds.length === 0) return roles; - + private prepareRoles>(roles: T[], user: MiUser, iAmModerator: boolean): T[] { const hiddenRoleIds = new Set(user.hiddenRoleIds); - return roles.filter(role => role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id)); + return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); } @bindThis @@ -547,8 +543,7 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得 - badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, iAmModerator, true) - .sort((a, b) => b.displayOrder - a.displayOrder) + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator) .map((r) => ({ id: r.id, name: r.name, @@ -593,18 +588,7 @@ export class UserEntityService implements OnModuleInit { followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), - roles: userRoles!.then(roles => this.filterHiddenDisplayRoles(roles.filter(role => role.isPublic), user, iAmModerator, !isMe).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ - id: role.id, - name: role.name, - color: role.color, - iconUrl: role.iconUrl, - description: role.description, - isModerator: role.isModerator, - isAdministrator: role.isAdministrator, - asBadge: role.asBadge, - isPublicDisplayRequired: role.isPublicDisplayRequired, - displayOrder: role.displayOrder, - }))), + roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator))), memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), From 9cc4c7d46be924ed44badbfddb749f3d2912750f Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:11:24 +0900 Subject: [PATCH 22/31] fix --- packages/frontend/src/pages/user/home.vue | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index cdcd577fbbf..f97c704dfe7 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -215,13 +215,6 @@ const props = withDefaults(defineProps<{ disableNotes: false, }); -type ProfileRole = Misskey.entities.UserDetailed['roles'][number] & { - isPublicDisplayRequired?: boolean; -}; -type MeDetailedWithRoleDisplay = Misskey.entities.MeDetailed & { - hiddenRoleIds?: string[]; -}; - const emit = defineEmits<{ (ev: 'showMoreFiles'): void; }>(); @@ -230,10 +223,10 @@ const router = useRouter(); const user = ref(props.user); const visibleProfileRoles = computed(() => { - const roles = user.value.roles as ProfileRole[]; + const roles = user.value.roles; if ($i == null || $i.id !== user.value.id) return roles; - const hiddenRoleIds = new Set((($i as MeDetailedWithRoleDisplay).hiddenRoleIds) ?? []); + const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []); return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id)); }); const narrow = ref(null); From 25aae46c59e8c3221fe2189a3912566c81d161da Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:19:29 +0900 Subject: [PATCH 23/31] fix test --- packages/backend/test/unit/entities/UserEntityService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 36ec426690d..d8e9c6cf581 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -30,6 +30,7 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; import { RoleService } from '@/core/RoleService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -182,6 +183,7 @@ describe('UserEntityService', () => { ApPersonService, NoteEntityService, PageEntityService, + RoleEntityService, CustomEmojiService, AnnouncementService, RoleService, From 28044973e74b7c53a6fbe6d604fa809bfdcb9806 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:23:49 +0900 Subject: [PATCH 24/31] fix --- packages/backend/src/core/entities/UserEntityService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index a27da9ab6c1..ed606ec8492 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -407,9 +407,9 @@ export class UserEntityService implements OnModuleInit { } @bindThis - private prepareRoles>(roles: T[], user: MiUser, iAmModerator: boolean): T[] { + private prepareRoles>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] { const hiddenRoleIds = new Set(user.hiddenRoleIds); - return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); + return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); } @bindThis @@ -588,7 +588,7 @@ export class UserEntityService implements OnModuleInit { followingVisibility: profile!.followingVisibility, chatScope: user.chatScope, canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), - roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator))), + roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))), memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), From 398f9a6b9b0caaa30e813262c36349cb5be2db9c Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:48:10 +0900 Subject: [PATCH 25/31] fix --- packages/backend/src/core/entities/UserEntityService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ed606ec8492..b9b346a76bb 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -409,7 +409,7 @@ export class UserEntityService implements OnModuleInit { @bindThis private prepareRoles>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] { const hiddenRoleIds = new Set(user.hiddenRoleIds); - return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); + return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder); } @bindThis From eeae7a3c16e038525b3b0ad865f14dd4f0dca2f1 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:09:04 +0900 Subject: [PATCH 26/31] fix --- packages/frontend/src/pages/settings/other.vue | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index a41d1d15091..4c03e701cad 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -115,8 +115,15 @@ SPDX-License-Identifier: AGPL-3.0-only

+
+ + + + {{ i18n.ts.rolesAssignedToMe }} + - {{ i18n.ts.registry }} + {{ i18n.ts.registry }} +

From 23ca8b2286849050bb59c9522ccdfbcc2b129e4e Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:13:11 +0900 Subject: [PATCH 27/31] fix --- packages/frontend/src/components/MkNoteDetailed.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 969e02dea98..62ca5e3061f 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -251,6 +251,7 @@ import { Paginator } from '@/utility/paginator.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; import { DI } from '@/di.js'; +import { $i } from '@/i.js'; import type { Keymap } from '@/utility/hotkey.js'; // コンポーネント外部の依存関係 @@ -332,6 +333,13 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji); // MkNoteDetailed固有 const tab = ref(props.initialTab); const reactionTabType = ref(null); +const badgeRoles = computed(() => { + const roles = appearNote.user.badgeRoles; + if (roles == null || $i == null || $i.id !== props.note.userId) return roles; + + const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []); + return roles.filter(role => !hiddenRoleIds.has(role.id)); +}); const renotesPaginator = markRaw(new Paginator('notes/renotes', { limit: 10, From 3df1f0e7809a6a6cd5a234a4089e3c03fd0fbf59 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:21:05 +0900 Subject: [PATCH 28/31] :v: --- locales/ja-JP.yml | 3 ++- packages/backend/src/core/entities/RoleEntityService.ts | 1 + packages/backend/src/models/json-schema/role.ts | 5 +++++ packages/frontend/src/pages/settings/roles.vue | 9 ++++++++- packages/i18n/src/autogen/locale.ts | 6 +++++- packages/misskey-js/src/autogen/types.ts | 2 ++ 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0619c79d48b..981f580d1f9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3246,7 +3246,8 @@ _gridComponent: notUnique: "この値は一意である必要があります" _roleDisplay: - description: "自分のプロフィールやノートに表示するロールを選択します。" + description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。" + roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。" displayToggle: "ロール/ロールバッジを表示する" alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。" diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index b6b30080f47..b2e49d81f6b 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -101,6 +101,7 @@ export class RoleEntityService { isAdministrator: role.isAdministrator, asBadge: role.asBadge, isPublicDisplayRequired: role.isPublicDisplayRequired, + isExplorable: role.isExplorable, displayOrder: role.displayOrder, }; } diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index ef44e2b0658..2a46b5d8c85 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -379,6 +379,11 @@ export const packedRoleLiteSchema = { optional: false, nullable: false, example: false, }, + isExplorable: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, displayOrder: { type: 'integer', optional: false, nullable: false, diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue index bcb51d1a469..57cffd5697c 100644 --- a/packages/frontend/src/pages/settings/roles.vue +++ b/packages/frontend/src/pages/settings/roles.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
+
diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index ec8c619e439..ce8a57b0d99 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -12181,9 +12181,13 @@ export interface Locale extends ILocale { }; "_roleDisplay": { /** - * 自分のプロフィールやノートに表示するロールを選択します。 + * 自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。 */ "description": string; + /** + * このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。 + */ + "roleExplorableAlert": ParameterizedString<"link">; /** * ロール/ロールバッジを表示する */ diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 860a085378e..7f3e9982d8e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5329,6 +5329,8 @@ export type components = { asBadge: boolean; /** @example false */ isPublicDisplayRequired: boolean; + /** @example false */ + isExplorable: boolean; /** @example 0 */ displayOrder: number; }; From eaac2544985d5cb00fe8df5cf459147a03781b66 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:26:06 +0900 Subject: [PATCH 29/31] fix --- packages/backend/test/e2e/users.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index e610e466497..459a358478a 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -663,6 +663,7 @@ describe('ユーザー', () => { isAdministrator: rolePublic.isAdministrator, asBadge: rolePublic.asBadge, isPublicDisplayRequired: rolePublic.isPublicDisplayRequired, + isExplorable: rolePublic.isExplorable, displayOrder: rolePublic.displayOrder, }]); }); From 87b88d73471b5647a5ca7c2efe5c1eac9aaa8a4c Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:29:32 +0900 Subject: [PATCH 30/31] add comment --- packages/frontend/src/components/MkNoteDetailed.vue | 1 + packages/frontend/src/components/MkNoteHeader.vue | 1 + packages/frontend/src/pages/user/home.vue | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 62ca5e3061f..ceeefd69c23 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -337,6 +337,7 @@ const badgeRoles = computed(() => { const roles = appearNote.user.badgeRoles; if (roles == null || $i == null || $i.id !== props.note.userId) return roles; + // 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []); return roles.filter(role => !hiddenRoleIds.has(role.id)); }); diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 4506fa4addd..d05a5da25bf 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -51,6 +51,7 @@ const badgeRoles = computed(() => { const roles = props.note.user.badgeRoles; if (roles == null || $i == null || $i.id !== props.note.userId) return roles; + // 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []); return roles.filter(role => !hiddenRoleIds.has(role.id)); }); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index f97c704dfe7..7fd6c2f626e 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -226,6 +226,7 @@ const visibleProfileRoles = computed(() => { const roles = user.value.roles; if ($i == null || $i.id !== user.value.id) return roles; + // 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []); return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id)); }); From 4d987e8843201bbbc49468223c7d2a9b748377ed Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:48:11 +0900 Subject: [PATCH 31/31] fix test --- packages/backend/test/e2e/users.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 459a358478a..cd1689154a3 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -685,6 +685,7 @@ describe('ユーザー', () => { isAdministrator: roleBadge.isAdministrator, asBadge: roleBadge.asBadge, isPublicDisplayRequired: roleBadge.isPublicDisplayRequired, + isExplorable: roleBadge.isExplorable, displayOrder: roleBadge.displayOrder, }]); });