diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 55847cca4ea0d5408847c4b848f08da4fba65df2..26e055f5cdf001313d6bf8c0c1d1c73e1f0194dd 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -685,6 +685,19 @@ accentColor: "アクセント"
 textColor: "文字"
 saveAs: "名前を付けて保存"
 advanced: "高度"
+value: "値"
+updatedAt: "更新日時"
+saveConfirm: "保存しますか?"
+deleteConfirm: "削除しますか?"
+invalidValue: "有効な値ではありません。"
+registry: "レジストリ"
+
+_registry:
+  scope: "スコープ"
+  key: "キー"
+  keys: "キー"
+  domain: "ドメイン"
+  createKey: "キーを作成"
 
 _aboutMisskey:
   about: "Misskeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。"
@@ -1558,6 +1571,7 @@ _deck:
   swapDown: "下に移動"
   stackLeft: "左に重ねる"
   popRight: "右に出す"
+  profile: "プロファイル"
 
   _columns:
     main: "メイン"
diff --git a/migration/1610277136869-registry.ts b/migration/1610277136869-registry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..46c8113c1628a3e50896e9982fec7d323c815aee
--- /dev/null
+++ b/migration/1610277136869-registry.ts
@@ -0,0 +1,22 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class registry1610277136869 implements MigrationInterface {
+    name = 'registry1610277136869'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TABLE "registry_item" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "key" character varying(1024) NOT NULL, "scope" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[], "domain" character varying(512), CONSTRAINT "PK_64b3f7e6008b4d89b826cd3af95" PRIMARY KEY ("id")); COMMENT ON COLUMN "registry_item"."createdAt" IS 'The created date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."updatedAt" IS 'The updated date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "registry_item"."key" IS 'The key of the RegistryItem.'`);
+        await queryRunner.query(`CREATE INDEX "IDX_fb9d21ba0abb83223263df6bcb" ON "registry_item" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_22baca135bb8a3ea1a83d13df3" ON "registry_item" ("scope") `);
+        await queryRunner.query(`CREATE INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad" ON "registry_item" ("domain") `);
+        await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "registry_item" DROP CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3"`);
+        await queryRunner.query(`DROP INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad"`);
+        await queryRunner.query(`DROP INDEX "IDX_22baca135bb8a3ea1a83d13df3"`);
+        await queryRunner.query(`DROP INDEX "IDX_fb9d21ba0abb83223263df6bcb"`);
+        await queryRunner.query(`DROP TABLE "registry_item"`);
+    }
+
+}
diff --git a/migration/1610277585759-registry2.ts b/migration/1610277585759-registry2.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2f2d80c48639e0851a917766fdb225dba399acac
--- /dev/null
+++ b/migration/1610277585759-registry2.ts
@@ -0,0 +1,16 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class registry21610277585759 implements MigrationInterface {
+    name = 'registry21610277585759'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "registry_item" ADD "value" jsonb NOT NULL DEFAULT '{}'`);
+        await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`);
+        await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "value"`);
+    }
+
+}
diff --git a/migration/1610283021566-registry3.ts b/migration/1610283021566-registry3.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61f235fb221f652eee92cdfc50144b245c2d45e1
--- /dev/null
+++ b/migration/1610283021566-registry3.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class registry31610283021566 implements MigrationInterface {
+    name = 'registry31610283021566'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" DROP NOT NULL`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" SET NOT NULL`);
+    }
+
+}
diff --git a/package.json b/package.json
index cd7cc0ff01210b4c1dd79bdeb372ff26511eabf8..685a5deadc82a78f05fb8bc2f8a6b986a830f00c 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
 		"@types/sharp": "0.26.1",
 		"@types/sinonjs__fake-timers": "6.0.1",
 		"@types/speakeasy": "2.0.5",
+		"@types/throttle-debounce": "2.1.0",
 		"@types/tinycolor2": "1.4.2",
 		"@types/tmp": "0.2.0",
 		"@types/uuid": "8.3.0",
@@ -232,6 +233,7 @@
 		"syuilo-password-strength": "0.0.1",
 		"textarea-caret": "3.1.0",
 		"three": "0.117.1",
+		"throttle-debounce": "3.0.1",
 		"tinycolor2": "1.4.2",
 		"tmp": "0.2.1",
 		"ts-loader": "8.0.11",
diff --git a/src/client/account.ts b/src/client/account.ts
index fdf49ee213308f759911bdc3193f889fd5aa5c25..e6ee8613d2ee24f6541da2fcc4fb79dc3480e332 100644
--- a/src/client/account.ts
+++ b/src/client/account.ts
@@ -7,7 +7,6 @@ import { waiting } from '@/os';
 type Account = {
 	id: string;
 	token: string;
-	clientData: Record<string, any>;
 };
 
 const data = localStorage.getItem('account');
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 19773b3b6cb3267c8cd6db8c6a80da089ba31e5d..bf300eebd8bd0c85de8019764fb737a690428f7e 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -262,7 +262,7 @@ export default defineComponent({
 		}
 
 		// keep cw when reply
-		if (this.$store.keepCw && this.reply && this.reply.cw) {
+		if (this.$store.state.keepCw && this.reply && this.reply.cw) {
 			this.useCw = true;
 			this.cw = this.reply.cw;
 		}
diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue
index 3bdb69b3d14990df808e8ef59c39b11afeba0796..5c71b14a0a7bb8ac7e35cff1869c1041e3febcbf 100644
--- a/src/client/components/ui/info.vue
+++ b/src/client/components/ui/info.vue
@@ -34,7 +34,7 @@ export default defineComponent({
 	font-size: 90%;
 	background: var(--infoBg);
 	color: var(--infoFg);
-	border-radius: 5px;
+	border-radius: var(--radius);
 
 	&.warn {
 		background: var(--infoWarnBg);
diff --git a/src/client/init.ts b/src/client/init.ts
index f39f50eea65974c1ad1e1aee3fd51449abe4186d..f09097fe314bdddbb69e1174c4ee392eba1c12b6 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -347,14 +347,6 @@ if ($i) {
 		updateAccount({ hasUnreadAnnouncement: false });
 	});
 
-	main.on('clientSettingUpdated', x => {
-		updateAccount({
-			clientData: {
-				[x.key]: x.value
-			}
-		});
-	});
-
 	// トークンが再生成されたとき
 	// このままではMisskeyが利用できないので強制的にサインアウトさせる
 	main.on('myTokenRegenerated', () => {
diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue
index 0d9f1ab0aa300c2ef2df7734befa8a9331aee421..30d36d4a06e4952186c668acab7143a4f48720ab 100644
--- a/src/client/pages/settings/deck.vue
+++ b/src/client/pages/settings/deck.vue
@@ -24,6 +24,8 @@
 		<span>{{ $ts._deck.columnMargin }}</span>
 		<template #suffix>px</template>
 	</FormInput>
+
+	<FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink>
 </FormBase>
 </template>
 
@@ -31,7 +33,7 @@
 import { defineComponent } from 'vue';
 import { faImage, faCog, faColumns } from '@fortawesome/free-solid-svg-icons';
 import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormInput from '@/components/form/input.vue';
 import FormBase from '@/components/form/base.vue';
@@ -42,7 +44,7 @@ import * as os from '@/os';
 export default defineComponent({
 	components: {
 		FormSwitch,
-		FormSelect,
+		FormLink,
 		FormInput,
 		FormRadios,
 		FormBase,
@@ -67,6 +69,7 @@ export default defineComponent({
 		columnAlign: deckStore.makeGetterSetter('columnAlign'),
 		columnMargin: deckStore.makeGetterSetter('columnMargin'),
 		columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'),
+		profile: deckStore.makeGetterSetter('profile'),
 	},
 
 	watch: {
@@ -85,5 +88,19 @@ export default defineComponent({
 	mounted() {
 		this.$emit('info', this.INFO);
 	},
+
+	methods: {
+		async setProfile() {
+			const { canceled, result: name } = await os.dialog({
+				title: this.$ts._deck.profile,
+				input: {
+					allowEmpty: false
+				}
+			});
+			if (canceled) return;
+			this.profile = name;
+			location.reload();
+		}
+	}
 });
 </script>
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
index aa9fe27164c70a50ee50e4892379591357fe3b7d..0f95a76f114b0195d7b342d6f083663d21f48aa3 100644
--- a/src/client/pages/settings/index.vue
+++ b/src/client/pages/settings/index.vue
@@ -35,13 +35,13 @@
 		</FormGroup>
 	</FormBase>
 	<div class="main">
-		<component :is="component" @info="onInfo"/>
+		<component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
 	</div>
 </div>
 </template>
 
 <script lang="ts">
-import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
 import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes } from '@fortawesome/free-solid-svg-icons';
 import { faLaugh, faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
 import { i18n } from '@/i18n';
@@ -78,7 +78,9 @@ export default defineComponent({
 		const onInfo = (viewInfo) => {
 			INFO.value = viewInfo;
 		};
+		const pageProps = ref({});
 		const component = computed(() => {
+			if (props.page == null) return null;
 			switch (props.page) {
 				case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
 				case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
@@ -104,16 +106,35 @@ export default defineComponent({
 				case 'plugins': return defineAsyncComponent(() => import('./plugins.vue'));
 				case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
 				case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
+				case 'registry': return defineAsyncComponent(() => import('./registry.vue'));
 				case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue'));
-				default: return null;
+			}
+			if (props.page.startsWith('registry/keys/system/')) {
+				return defineAsyncComponent(() => import('./registry.keys.vue'));
+			}
+			if (props.page.startsWith('registry/value/system/')) {
+				return defineAsyncComponent(() => import('./registry.value.vue'));
 			}
 		});
 
 		watch(component, () => {
+			pageProps.value = {};
+
+			if (props.page) {
+				if (props.page.startsWith('registry/keys/system/')) {
+					pageProps.value.scope = props.page.replace('registry/keys/system/', '').split('/');
+				}
+				if (props.page.startsWith('registry/value/system/')) {
+					const path = props.page.replace('registry/value/system/', '').split('/');
+					pageProps.value.xKey = path.pop();
+					pageProps.value.scope = path;
+				}
+			}
+
 			nextTick(() => {
 				scroll(el.value, 0);
 			});
-		});
+		}, { immediate: true });
 
 		onMounted(() => {
 			narrow.value = el.value.offsetWidth < 1025;
@@ -125,6 +146,7 @@ export default defineComponent({
 			view,
 			el,
 			onInfo,
+			pageProps,
 			component,
 			logout: () => {
 				signout();
diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue
index 67edaf3faa0300761f0628e5c2a91211816888c2..bc42b747d58ca60155fe9cce4dcb1ac4b9c995b2 100644
--- a/src/client/pages/settings/other.vue
+++ b/src/client/pages/settings/other.vue
@@ -15,16 +15,17 @@
 			DEBUG MODE
 		</FormSwitch>
 		<template v-if="debug">
-			<FormLink to="/settings/regedit">RegEdit</FormLink>
 			<FormButton @click="taskmanager">Task Manager</FormButton>
 		</template>
 	</FormGroup>
+
+	<FormLink to="/settings/registry"><template #icon><Fa :icon="faCogs"/></template>{{ $ts.registry }}</FormLink>
 </FormBase>
 </template>
 
 <script lang="ts">
 import { defineAsyncComponent, defineComponent } from 'vue';
-import { faEllipsisH } from '@fortawesome/free-solid-svg-icons';
+import { faEllipsisH, faCogs } from '@fortawesome/free-solid-svg-icons';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSelect from '@/components/form/select.vue';
 import FormLink from '@/components/form/link.vue';
@@ -53,7 +54,8 @@ export default defineComponent({
 				title: this.$ts.other,
 				icon: faEllipsisH
 			},
-			debug
+			debug,
+			faCogs
 		}
 	},
 
diff --git a/src/client/pages/settings/registry.keys.vue b/src/client/pages/settings/registry.keys.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c7a90fb4618f5920762b746b39b2cade6da0b885
--- /dev/null
+++ b/src/client/pages/settings/registry.keys.vue
@@ -0,0 +1,115 @@
+<template>
+<FormBase>
+	<FormGroup>
+		<FormKeyValueView>
+			<template #key>{{ $ts._registry.domain }}</template>
+			<template #value>{{ $ts.system }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $ts._registry.scope }}</template>
+			<template #value>{{ scope.join('/') }}</template>
+		</FormKeyValueView>
+	</FormGroup>
+	
+	<FormGroup v-if="keys">
+		<template #label>{{ $ts._registry.keys }}</template>
+		<FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+	</FormGroup>
+
+	<FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkInfo,
+		FormBase,
+		FormSelect,
+		FormSwitch,
+		FormButton,
+		FormLink,
+		FormGroup,
+		FormKeyValueView,
+	},
+
+	props: {
+		scope: {
+			required: true
+		}
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$ts.registry,
+				icon: faCogs
+			},
+			keys: null,
+		}
+	},
+
+	watch: {
+		scope() {
+			this.fetch();
+		}
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+		this.fetch();
+	},
+
+	methods: {
+		fetch() {
+			os.api('i/registry/keys-with-type', {
+				scope: this.scope
+			}).then(keys => {
+				this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0]));
+			});
+		},
+
+		async createKey() {
+			const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+				key: {
+					type: 'string',
+					label: this.$ts._registry.key,
+				},
+				value: {
+					type: 'string',
+					multiline: true,
+					label: this.$ts.value,
+				},
+				scope: {
+					type: 'string',
+					label: this.$ts._registry.scope,
+					default: this.scope.join('/')
+				}
+			});
+			if (canceled) return;
+			os.apiWithDialog('i/registry/set', {
+				scope: result.scope.split('/'),
+				key: result.key,
+				value: JSON5.parse(result.value),
+			}).then(() => {
+				this.fetch();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/settings/registry.value.vue b/src/client/pages/settings/registry.value.vue
new file mode 100644
index 0000000000000000000000000000000000000000..943ededd21b28ae89012a86d20c0743131c81fb0
--- /dev/null
+++ b/src/client/pages/settings/registry.value.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+	<MkInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</MkInfo>
+
+	<template v-if="value">
+		<FormGroup>
+			<FormKeyValueView>
+				<template #key>{{ $ts._registry.domain }}</template>
+				<template #value>{{ $ts.system }}</template>
+			</FormKeyValueView>
+			<FormKeyValueView>
+				<template #key>{{ $ts._registry.scope }}</template>
+				<template #value>{{ scope.join('/') }}</template>
+			</FormKeyValueView>
+			<FormKeyValueView>
+				<template #key>{{ $ts._registry.key }}</template>
+				<template #value>{{ xKey }}</template>
+			</FormKeyValueView>
+		</FormGroup>
+
+		<FormGroup>
+			<FormTextarea tall v-model:value="valueForEditor" class="_monospace" style="tab-size: 2;">
+				<span>{{ $ts.value }} (JSON)</span>
+			</FormTextarea>
+			<FormButton @click="save" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
+		</FormGroup>
+
+		<FormKeyValueView>
+			<template #key>{{ $ts.updatedAt }}</template>
+			<template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+		</FormKeyValueView>
+
+		<FormButton danger @click="del"><Fa :icon="faTrash"/> {{ $ts.delete }}</FormButton>
+	</template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs, faSave, faTrash } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkInfo,
+		FormBase,
+		FormSelect,
+		FormSwitch,
+		FormButton,
+		FormTextarea,
+		FormGroup,
+		FormKeyValueView,
+	},
+
+	props: {
+		scope: {
+			required: true
+		},
+		xKey: {
+			required: true
+		},
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$ts.registry,
+				icon: faCogs
+			},
+			value: null,
+			valueForEditor: null,
+			faSave, faTrash,
+		}
+	},
+
+	watch: {
+		key() {
+			this.fetch();
+		},
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+		this.fetch();
+	},
+
+	methods: {
+		fetch() {
+			os.api('i/registry/get-detail', {
+				scope: this.scope,
+				key: this.xKey
+			}).then(value => {
+				this.value = value;
+				this.valueForEditor = JSON5.stringify(this.value.value, null, '\t');
+			});
+		},
+
+		save() {
+			try {
+				JSON5.parse(this.valueForEditor);
+			} catch (e) {
+				os.dialog({
+					type: 'error',
+					text: this.$ts.invalidValue
+				});
+				return;
+			}
+
+			os.dialog({
+				type: 'warning',
+				text: this.$ts.saveConfirm,
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+				os.apiWithDialog('i/registry/set', {
+					scope: this.scope,
+					key: this.xKey,
+					value: JSON5.parse(this.valueForEditor)
+				});
+			});
+		},
+
+		del() {
+			os.dialog({
+				type: 'warning',
+				text: this.$ts.deleteConfirm,
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+				os.apiWithDialog('i/registry/remove', {
+					scope: this.scope,
+					key: this.xKey
+				});
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/settings/registry.vue b/src/client/pages/settings/registry.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a43c98e730be8cb4b73e3b6175a6bff41b50cbd2
--- /dev/null
+++ b/src/client/pages/settings/registry.vue
@@ -0,0 +1,91 @@
+<template>
+<FormBase>
+	<FormGroup v-if="scopes">
+		<template #label>{{ $ts.system }}</template>
+		<FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+	</FormGroup>
+	<FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkInfo,
+		FormBase,
+		FormSelect,
+		FormSwitch,
+		FormButton,
+		FormLink,
+		FormGroup,
+		FormKeyValueView,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$ts.registry,
+				icon: faCogs
+			},
+			scopes: null,
+		}
+	},
+
+	created() {
+		this.fetch();
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+		fetch() {
+			os.api('i/registry/scopes').then(scopes => {
+				this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+			});
+		},
+
+		async createKey() {
+			const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+				key: {
+					type: 'string',
+					label: this.$ts._registry.key,
+				},
+				value: {
+					type: 'string',
+					multiline: true,
+					label: this.$ts.value,
+				},
+				scope: {
+					type: 'string',
+					label: this.$ts._registry.scope,
+				}
+			});
+			if (canceled) return;
+			os.apiWithDialog('i/registry/set', {
+				scope: result.scope.split('/'),
+				key: result.key,
+				value: JSON5.parse(result.value),
+			}).then(() => {
+				this.fetch();
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pizzax.ts b/src/client/pizzax.ts
index fdaf2bebb6ea161f2d5918cf3635a467acb8adbe..794738edd46602e43b21d4df00f8fc4eb2fe28a2 100644
--- a/src/client/pizzax.ts
+++ b/src/client/pizzax.ts
@@ -11,6 +11,7 @@ type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
 
 export class Storage<T extends StateDef> {
 	public readonly key: string;
+	public readonly keyForLocalStorage: string;
 
 	public readonly def: T;
 
@@ -19,20 +20,22 @@ export class Storage<T extends StateDef> {
 	public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> };
 
 	constructor(key: string, def: T) {
-		this.key = 'pizzax::' + key;
+		this.key = key;
+		this.keyForLocalStorage = 'pizzax::' + key;
 		this.def = def;
 
 		// TODO: indexedDBにする
-		const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}');
-		const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}') : {};
+		const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
+		const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {};
+		const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {};
 
 		const state = {};
 		const reactiveState = {};
 		for (const [k, v] of Object.entries(def)) {
 			if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
 				state[k] = deviceState[k];
-			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call($i.clientData, k)) {
-				state[k] = $i.clientData[k];
+			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
+				state[k] = registryCache[k];
 			} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
 				state[k] = deviceAccountState[k];
 			} else {
@@ -47,16 +50,24 @@ export class Storage<T extends StateDef> {
 		this.reactiveState = reactiveState as any;
 
 		if ($i) {
-			watch($i, () => {
-				if (_DEV_) console.log('$i updated');
-
-				for (const [k, v] of Object.entries(def)) {
-					if (v.where === 'account' && Object.prototype.hasOwnProperty.call($i!.clientData, k)) {
-						state[k] = $i!.clientData[k];
-						reactiveState[k].value = $i!.clientData[k];
+			// なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう)
+			setTimeout(() => {
+				api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => {
+					for (const [k, v] of Object.entries(def)) {
+						if (v.where === 'account') {
+							if (Object.prototype.hasOwnProperty.call(kvs, k)) {
+								state[k] = kvs[k];
+								reactiveState[k].value = kvs[k];
+							} else {
+								state[k] = v.default;
+								reactiveState[k].value = v.default;
+							}
+						}
 					}
-				}
-			});
+				});
+			}, 1);
+
+			// TODO: streamingのuser storage updateイベントを監視して更新
 		}
 	}
 
@@ -68,21 +79,26 @@ export class Storage<T extends StateDef> {
 
 		switch (this.def[key].where) {
 			case 'device': {
-				const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}');
+				const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
 				deviceState[key] = value;
-				localStorage.setItem(this.key, JSON.stringify(deviceState));
+				localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState));
 				break;
 			}
 			case 'deviceAccount': {
 				if ($i == null) break;
-				const deviceAccountState = JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}');
+				const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}');
 				deviceAccountState[key] = value;
-				localStorage.setItem(this.key + '::' + $i.id, JSON.stringify(deviceAccountState));
+				localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState));
 				break;
 			}
 			case 'account': {
-				api('i/update-client-setting', {
-					name: key,
+				if ($i == null) break;
+				const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
+				cache[key] = value;
+				localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
+				api('i/registry/set', {
+					scope: ['client', this.key],
+					key: key,
 					value: value
 				});
 				break;
diff --git a/src/client/router.ts b/src/client/router.ts
index 5753a47024b35760ec78df714e1113569814b3c7..6f79426b23cf4f8d3496618e8174937cf5f8e12a 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -81,7 +81,6 @@ export const router = createRouter({
 		{ path: '/miauth/:session', component: page('miauth') },
 		{ path: '/authorize-follow', component: page('follow') },
 		{ path: '/share', component: page('share') },
-		{ path: '/test', component: page('test') },
 		{ path: '/:catchAll(.*)', component: page('not-found') }
 	],
 	// なんかHacky
diff --git a/src/client/ui/deck.vue b/src/client/ui/deck.vue
index 099a6f60c61dc37c79b2926c5c4a3604f2d9c5da..a074629ddd51650f769c3ff1f5dfd784e8db119c 100644
--- a/src/client/ui/deck.vue
+++ b/src/client/ui/deck.vue
@@ -41,7 +41,7 @@ import { getScrollContainer } from '@/scripts/scroll';
 import * as os from '@/os';
 import { sidebarDef } from '@/sidebar';
 import XCommon from './_common_/common.vue';
-import { deckStore, addColumn } from './deck/deck-store';
+import { deckStore, addColumn, loadDeck } from './deck/deck-store';
 
 export default defineComponent({
 	components: {
@@ -88,6 +88,7 @@ export default defineComponent({
 		document.documentElement.style.overflowY = 'hidden';
 		document.documentElement.style.scrollBehavior = 'auto';
 		window.addEventListener('wheel', this.onWheel);
+		loadDeck();
 	},
 
 	mounted() {
diff --git a/src/client/ui/deck/deck-store.ts b/src/client/ui/deck/deck-store.ts
index 3d2e1873d3856c03060bb63ab52a788b7d9b31b9..93ea0a322857412a8005e216d250b2348efe6dba 100644
--- a/src/client/ui/deck/deck-store.ts
+++ b/src/client/ui/deck/deck-store.ts
@@ -1,5 +1,7 @@
+import { throttle } from 'throttle-debounce';
 import { i18n } from '@/i18n';
-import { markRaw } from 'vue';
+import { api } from '@/os';
+import { markRaw, watch } from 'vue';
 import { Storage } from '../../pizzax';
 
 type ColumnWidget = {
@@ -21,23 +23,17 @@ function copy<T>(x: T): T {
 }
 
 export const deckStore = markRaw(new Storage('deck', {
+	profile: {
+		where: 'deviceAccount',
+		default: 'default'
+	},
 	columns: {
 		where: 'deviceAccount',
-		default: [{
-			id: 'a',
-			type: 'main',
-			name: i18n.locale._deck._columns.main,
-			width: 350,
-		}, {
-			id: 'b',
-			type: 'notifications',
-			name: i18n.locale._deck._columns.notifications,
-			width: 330,
-		}] as Column[]
+		default: [] as Column[]
 	},
 	layout: {
 		where: 'deviceAccount',
-		default: [['a'], ['b']] as Column['id'][][]
+		default: [] as Column['id'][][]
 	},
 	columnAlign: {
 		where: 'deviceAccount',
@@ -61,10 +57,60 @@ export const deckStore = markRaw(new Storage('deck', {
 	},
 }));
 
+export const loadDeck = async () => {
+	let deck;
+
+	try {
+		deck = await api('i/registry/get', {
+			scope: ['client', 'deck', 'profiles'],
+			key: deckStore.state.profile,
+		});
+	} catch (e) {
+		if (e.code === 'NO_SUCH_KEY') {
+			// 後方互換性のため
+			if (deckStore.state.profile === 'default') {
+				saveDeck();
+				return;
+			}
+
+			deckStore.set('columns', [{
+				id: 'a',
+				type: 'main',
+				name: i18n.locale._deck._columns.main,
+				width: 350,
+			}, {
+				id: 'b',
+				type: 'notifications',
+				name: i18n.locale._deck._columns.notifications,
+				width: 330,
+			}]);
+			deckStore.set('layout', [['a'], ['b']]);
+			return;
+		}
+		throw e;
+	}
+
+	deckStore.set('columns', deck.columns);
+	deckStore.set('layout', deck.layout);
+};
+
+// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
+export const saveDeck = throttle(1000, () => {
+	api('i/registry/set', {
+		scope: ['client', 'deck', 'profiles'],
+		key: deckStore.state.profile,
+		value: {
+			columns: deckStore.reactiveState.columns.value,
+			layout: deckStore.reactiveState.layout.value,
+		}
+	});
+});
+
 export function addColumn(column: Column) {
 	if (column.name == undefined) column.name = null;
 	deckStore.push('columns', column);
 	deckStore.push('layout', [column.id]);
+	saveDeck();
 }
 
 export function removeColumn(id: Column['id']) {
@@ -72,6 +118,7 @@ export function removeColumn(id: Column['id']) {
 	deckStore.set('layout', deckStore.state.layout
 		.map(ids => ids.filter(_id => _id !== id))
 		.filter(ids => ids.length > 0));
+	saveDeck();
 }
 
 export function swapColumn(a: Column['id'], b: Column['id']) {
@@ -83,6 +130,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) {
 	layout[aX][aY] = b;
 	layout[bX][bY] = a;
 	deckStore.set('layout', layout);
+	saveDeck();
 }
 
 export function swapLeftColumn(id: Column['id']) {
@@ -98,6 +146,7 @@ export function swapLeftColumn(id: Column['id']) {
 			return true;
 		}
 	});
+	saveDeck();
 }
 
 export function swapRightColumn(id: Column['id']) {
@@ -113,6 +162,7 @@ export function swapRightColumn(id: Column['id']) {
 			return true;
 		}
 	});
+	saveDeck();
 }
 
 export function swapUpColumn(id: Column['id']) {
@@ -132,6 +182,7 @@ export function swapUpColumn(id: Column['id']) {
 			return true;
 		}
 	});
+	saveDeck();
 }
 
 export function swapDownColumn(id: Column['id']) {
@@ -151,6 +202,7 @@ export function swapDownColumn(id: Column['id']) {
 			return true;
 		}
 	});
+	saveDeck();
 }
 
 export function stackLeftColumn(id: Column['id']) {
@@ -160,6 +212,7 @@ export function stackLeftColumn(id: Column['id']) {
 	layout[i - 1].push(id);
 	layout = layout.filter(ids => ids.length > 0);
 	deckStore.set('layout', layout);
+	saveDeck();
 }
 
 export function popRightColumn(id: Column['id']) {
@@ -169,6 +222,7 @@ export function popRightColumn(id: Column['id']) {
 	layout.splice(i + 1, 0, [id]);
 	layout = layout.filter(ids => ids.length > 0);
 	deckStore.set('layout', layout);
+	saveDeck();
 }
 
 export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
@@ -180,6 +234,7 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
 	column.widgets.unshift(widget);
 	columns[columnIndex] = column;
 	deckStore.set('columns', columns);
+	saveDeck();
 }
 
 export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
@@ -190,6 +245,7 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
 	column.widgets = column.widgets.filter(w => w.id != widget.id);
 	columns[columnIndex] = column;
 	deckStore.set('columns', columns);
+	saveDeck();
 }
 
 export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
@@ -200,6 +256,7 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
 	column.widgets = widgets;
 	columns[columnIndex] = column;
 	deckStore.set('columns', columns);
+	saveDeck();
 }
 
 export function updateColumnWidget(id: Column['id'], widgetId: string, data: any) {
@@ -213,6 +270,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, data: any
 	} : w);
 	columns[columnIndex] = column;
 	deckStore.set('columns', columns);
+	saveDeck();
 }
 
 export function updateColumn(id: Column['id'], column: Partial<Column>) {
@@ -225,4 +283,5 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) {
 	}
 	columns[columnIndex] = currentColumn;
 	deckStore.set('columns', columns);
+	saveDeck();
 }
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index e2acdeafd1515b561c7904bad89cb116c4e86200..2f3c910163ae07030f1485304c9b8c899a2e26b7 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -63,6 +63,7 @@ import { MutedNote } from '../models/entities/muted-note';
 import { Channel } from '../models/entities/channel';
 import { ChannelFollowing } from '../models/entities/channel-following';
 import { ChannelNotePining } from '../models/entities/channel-note-pining';
+import { RegistryItem } from '../models/entities/registry-item';
 
 const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
 
@@ -159,6 +160,7 @@ export const entities = [
 	Channel,
 	ChannelFollowing,
 	ChannelNotePining,
+	RegistryItem,
 	...charts as any
 ];
 
diff --git a/src/models/entities/registry-item.ts b/src/models/entities/registry-item.ts
new file mode 100644
index 0000000000000000000000000000000000000000..54d2ef208258b1d754ccbec530d1e875813aef4c
--- /dev/null
+++ b/src/models/entities/registry-item.ts
@@ -0,0 +1,58 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+
+// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
+@Entity()
+export class RegistryItem {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the RegistryItem.'
+	})
+	public createdAt: Date;
+
+	@Column('timestamp with time zone', {
+		comment: 'The updated date of the RegistryItem.'
+	})
+	public updatedAt: Date;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: 'The owner ID.'
+	})
+	public userId: User['id'];
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE'
+	})
+	@JoinColumn()
+	public user: User | null;
+
+	@Column('varchar', {
+		length: 1024,
+		comment: 'The key of the RegistryItem.'
+	})
+	public key: string;
+
+	@Column('jsonb', {
+		default: {}, nullable: true,
+		comment: 'The value of the RegistryItem.'
+	})
+	public value: any | null;
+
+	@Index()
+	@Column('varchar', {
+		length: 1024, array: true, default: '{}'
+	})
+	public scope: string[];
+
+	// サードパーティアプリに開放するときのためのカラム
+	@Index()
+	@Column('varchar', {
+		length: 512, nullable: true
+	})
+	public domain: string | null;
+}
diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts
index 97a4150be0f6305c532c6c5f6e59b3f5613bfa4d..0e2c66032597a2d73ba69b4c78106033e0d9b9aa 100644
--- a/src/models/entities/user-profile.ts
+++ b/src/models/entities/user-profile.ts
@@ -94,6 +94,7 @@ export class UserProfile {
 	})
 	public password: string | null;
 
+	// TODO: そのうち消す
 	@Column('jsonb', {
 		default: {},
 		comment: 'The client-specific data of the User.'
diff --git a/src/models/index.ts b/src/models/index.ts
index dd05dcbcc6b9d0761f7db15c49dcdd6133e01602..213570a9c4bbccb4f2f1b8964fb99c42ffb5ce0e 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -57,6 +57,7 @@ import { ChannelRepository } from './repositories/channel';
 import { MutedNote } from './entities/muted-note';
 import { ChannelFollowing } from './entities/channel-following';
 import { ChannelNotePining } from './entities/channel-note-pining';
+import { RegistryItem } from './entities/registry-item';
 
 export const Announcements = getRepository(Announcement);
 export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -116,3 +117,4 @@ export const MutedNotes = getRepository(MutedNote);
 export const Channels = getCustomRepository(ChannelRepository);
 export const ChannelFollowings = getRepository(ChannelFollowing);
 export const ChannelNotePinings = getRepository(ChannelNotePining);
+export const RegistryItems = getRepository(RegistryItem);
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 29facf52397311507873755b5b0e8e0c654cd6a9..7bf11b31678576c119f05e6de4f570bf7a37f06b 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -261,7 +261,6 @@ export class UserRepository extends Repository<User> {
 			} : {}),
 
 			...(opts.includeSecrets ? {
-				clientData: profile!.clientData,
 				email: profile!.email,
 				emailVerified: profile!.emailVerified,
 				securityKeysList: profile!.twoFactorEnabled
diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts
index 7fbc200fc0cd210507ae39b15227a8a0e083a0df..80a4fd97c886221cadf5ec6d7b7786252a2787c7 100644
--- a/src/server/api/api-handler.ts
+++ b/src/server/api/api-handler.ts
@@ -11,7 +11,7 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => {
 	const reply = (x?: any, y?: ApiError) => {
 		if (x == null) {
 			ctx.status = 204;
-		} else if (typeof x === 'number') {
+		} else if (typeof x === 'number' && y) {
 			ctx.status = x;
 			ctx.body = {
 				error: {
@@ -23,7 +23,8 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => {
 				}
 			};
 		} else {
-			ctx.body = x;
+			// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない
+			ctx.body = typeof x === 'string' ? JSON.stringify(x) : x;
 		}
 		res();
 	};
diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts
index bceb9548ef62e55067c03f357ced5d3d70b52e05..3d0c092adb0cbb67989eb291b28d988dab8e9f90 100644
--- a/src/server/api/endpoints/i.ts
+++ b/src/server/api/endpoints/i.ts
@@ -1,5 +1,7 @@
 import define from '../define';
-import { Users } from '../../../models';
+import { RegistryItems, UserProfiles, Users } from '../../../models';
+import { ensure } from '../../../prelude/ensure';
+import { genId } from '../../../misc/gen-id';
 
 export const meta = {
 	desc: {
@@ -22,6 +24,27 @@ export const meta = {
 export default define(meta, async (ps, user, token) => {
 	const isSecure = token == null;
 
+	// TODO: そのうち消す
+	const profile = await UserProfiles.findOne(user.id).then(ensure);
+	for (const [k, v] of Object.entries(profile.clientData)) {
+		await RegistryItems.insert({
+			id: genId(),
+			createdAt: new Date(),
+			updatedAt: new Date(),
+			userId: user.id,
+			domain: null,
+			scope: ['client', 'base'],
+			key: k,
+			value: v
+		});
+	}
+	await UserProfiles.createQueryBuilder().update()
+		.set({
+			clientData: {},
+		})
+		.where('userId = :id', { id: user.id })
+		.execute();
+
 	return await Users.pack(user, user, {
 		detail: true,
 		includeSecrets: isSecure
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index fd355dab83c7c01f4e525a00739cea4878ca5819..0e09bc73fdd7b2c8fb06c8da03d4574eb2f0c20b 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -80,7 +80,7 @@ export default define(meta, async (ps, user) => {
 		.where('muting.muterId = :muterId', { muterId: user.id });
 
 	const suspendedQuery = Users.createQueryBuilder('users')
-		.select('id')
+		.select('users.id')
 		.where('users.isSuspended = TRUE');
 
 	const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId)
diff --git a/src/server/api/endpoints/i/registry/get-all.ts b/src/server/api/endpoints/i/registry/get-all.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ce8653f22bfb0c06d7b2361b4421e3b3c5f12a6c
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get-all.ts
@@ -0,0 +1,33 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const items = await query.getMany();
+
+	const res = {} as Record<string, any>;
+
+	for (const item of items) {
+		res[item.key] = item.value;
+	}
+
+	return res;
+});
diff --git a/src/server/api/endpoints/i/registry/get-detail.ts b/src/server/api/endpoints/i/registry/get-detail.ts
new file mode 100644
index 0000000000000000000000000000000000000000..441833d3d79897982f4a889345525ebecd748540
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get-detail.ts
@@ -0,0 +1,48 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		key: {
+			validator: $.str
+		},
+
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	},
+
+	errors: {
+		noSuchKey: {
+			message: 'No such key.',
+			code: 'NO_SUCH_KEY',
+			id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a'
+		},
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.key = :key', { key: ps.key })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const item = await query.getOne();
+
+	if (item == null) {
+		throw new ApiError(meta.errors.noSuchKey);
+	}
+
+	return {
+		updatedAt: item.updatedAt,
+		value: item.value,
+	};
+});
diff --git a/src/server/api/endpoints/i/registry/get.ts b/src/server/api/endpoints/i/registry/get.ts
new file mode 100644
index 0000000000000000000000000000000000000000..275e660cb6843e15f08ecfed36853e60af7db5e8
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		key: {
+			validator: $.str
+		},
+
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	},
+
+	errors: {
+		noSuchKey: {
+			message: 'No such key.',
+			code: 'NO_SUCH_KEY',
+			id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a'
+		},
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.key = :key', { key: ps.key })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const item = await query.getOne();
+
+	if (item == null) {
+		throw new ApiError(meta.errors.noSuchKey);
+	}
+
+	return item.value;
+});
diff --git a/src/server/api/endpoints/i/registry/keys-with-type.ts b/src/server/api/endpoints/i/registry/keys-with-type.ts
new file mode 100644
index 0000000000000000000000000000000000000000..06d77acbeb80c363f4e4def33d52d1c5d4902706
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/keys-with-type.ts
@@ -0,0 +1,41 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const items = await query.getMany();
+
+	const res = {} as Record<string, string>;
+
+	for (const item of items) {
+		const type = typeof item.value;
+		res[item.key] =
+			item.value === null ? 'null' :
+			Array.isArray(item.value) ? 'array' :
+			type === 'number' ? 'number' :
+			type === 'string' ? 'string' :
+			type === 'boolean' ? 'boolean' :
+			type === 'object' ? 'object' :
+			null as never;
+	}
+
+	return res;
+});
diff --git a/src/server/api/endpoints/i/registry/keys.ts b/src/server/api/endpoints/i/registry/keys.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e4dd5044b44c0aeefce0b6d21be1acce911d8189
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/keys.ts
@@ -0,0 +1,28 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.select('item.key')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const items = await query.getMany();
+
+	return items.map(x => x.key);
+});
diff --git a/src/server/api/endpoints/i/registry/remove.ts b/src/server/api/endpoints/i/registry/remove.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e73444efd23c9114f98b69b2b9b6b86bf5d20853
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/remove.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		key: {
+			validator: $.str
+		},
+
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	},
+
+	errors: {
+		noSuchKey: {
+			message: 'No such key.',
+			code: 'NO_SUCH_KEY',
+			id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019'
+		},
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.key = :key', { key: ps.key })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const item = await query.getOne();
+
+	if (item == null) {
+		throw new ApiError(meta.errors.noSuchKey);
+	}
+
+	RegistryItems.remove(item);
+});
diff --git a/src/server/api/endpoints/i/registry/scopes.ts b/src/server/api/endpoints/i/registry/scopes.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b0e1a7fd8df0898d6215db2474fb6fa24552d54
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/scopes.ts
@@ -0,0 +1,30 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.select('item.scope')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id });
+
+	const items = await query.getMany();
+
+	const res = [] as string[][];
+
+	for (const item of items) {
+		if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue;
+		res.push(item.scope);
+	}
+
+	return res;
+});
diff --git a/src/server/api/endpoints/i/registry/set.ts b/src/server/api/endpoints/i/registry/set.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c732cfc8f5b7b49ee60016c7842edd24649fc271
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/set.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy';
+import { publishMainStream } from '../../../../../services/stream';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { genId } from '../../../../../misc/gen-id';
+
+export const meta = {
+	requireCredential: true as const,
+
+	secure: true,
+
+	params: {
+		key: {
+			validator: $.str.min(1)
+		},
+
+		value: {
+			validator: $.nullable.any
+		},
+
+		scope: {
+			validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+			default: [],
+		},
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const query = RegistryItems.createQueryBuilder('item')
+		.where('item.domain IS NULL')
+		.andWhere('item.userId = :userId', { userId: user.id })
+		.andWhere('item.key = :key', { key: ps.key })
+		.andWhere('item.scope = :scope', { scope: ps.scope });
+
+	const existingItem = await query.getOne();
+
+	if (existingItem) {
+		await RegistryItems.update(existingItem.id, {
+			updatedAt: new Date(),
+			value: ps.value
+		});
+	} else {
+		await RegistryItems.insert({
+			id: genId(),
+			createdAt: new Date(),
+			updatedAt: new Date(),
+			userId: user.id,
+			domain: null,
+			scope: ps.scope,
+			key: ps.key,
+			value: ps.value
+		});
+	}
+
+	// TODO: サードパーティアプリが傍受出来てしまうのでどうにかする
+	publishMainStream(user.id, 'registryUpdated', {
+		scope: ps.scope,
+		key: ps.key,
+		value: ps.value
+	});
+});
diff --git a/src/server/api/endpoints/i/update-client-setting.ts b/src/server/api/endpoints/i/update-client-setting.ts
deleted file mode 100644
index 5143d3d9ba322eb5d692bf3b34b84a86ff80604f..0000000000000000000000000000000000000000
--- a/src/server/api/endpoints/i/update-client-setting.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import $ from 'cafy';
-import { publishMainStream } from '../../../../services/stream';
-import define from '../../define';
-import { UserProfiles } from '../../../../models';
-import { ensure } from '../../../../prelude/ensure';
-
-export const meta = {
-	requireCredential: true as const,
-
-	secure: true,
-
-	params: {
-		name: {
-			validator: $.str.match(/^[a-zA-Z]+$/)
-		},
-
-		value: {
-			validator: $.nullable.any
-		}
-	}
-};
-
-export default define(meta, async (ps, user) => {
-	const profile = await UserProfiles.findOne(user.id).then(ensure);
-
-	await UserProfiles.createQueryBuilder().update()
-		.set({
-			clientData: Object.assign(profile.clientData, {
-				[ps.name]: ps.value
-			}),
-		})
-		.where('userId = :id', { id: user.id })
-		.execute();
-
-	// Publish event
-	publishMainStream(user.id, 'clientSettingUpdated', {
-		key: ps.name,
-		value: ps.value
-	});
-});
diff --git a/yarn.lock b/yarn.lock
index 44cd0f074eaa9663a944e52965216f3708ff5075..3c53fc5a1a3f47aaf5a52c49cc7242641c9c0c6e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -911,6 +911,11 @@
   resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02"
   integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==
 
+"@types/throttle-debounce@2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776"
+  integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==
+
 "@types/tinycolor2@1.4.2":
   version "1.4.2"
   resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf"
@@ -10025,6 +10030,11 @@ three@0.117.1:
   resolved "https://registry.yarnpkg.com/three/-/three-0.117.1.tgz#a49bcb1a6ddea2f250003e42585dc3e78e92b9d3"
   integrity sha512-t4zeJhlNzUIj9+ub0l6nICVimSuRTZJOqvk3Rmlu+YGdTOJ49Wna8p7aumpkXJakJfITiybfpYE1XN1o1Z34UQ==
 
+throttle-debounce@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"
+  integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==
+
 through2-filter@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"