[{"data":1,"prerenderedAt":166},["ShallowReactive",2],{"articles-1":3},{"contents":4,"totalCount":163,"offset":164,"limit":165},[5,25,47,55,72,81,95,110,118,128,137,151],{"id":6,"createdAt":7,"updatedAt":8,"publishedAt":8,"revisedAt":8,"title":9,"content":10,"tags":11,"is_no_index":24},"0s5wahzwf4","2026-07-13T20:36:41.461Z","2026-07-13T20:40:28.103Z","知性","\u003Cp>嫌な時代になったもので、わたしが知性を感じていたもの達は、軒並みLLMによって6割くらいの完成度で再現可能になってしまいました。\u003C/p>\u003Cp>文章も、コードも、設計も、映像も。おかげさまで世の中はslopまみれです。検索結果を開けばLLM製のまとめ記事、技術記事サイトを開けばどこかで見たような解説の劣化版焼き直し。SEO汚染で瀕死だったインターネットに、とどめの一撃が入った格好です。\u003C/p>\u003Cp>この6割という数字が、また嫌らしい。2割なら誰も相手にしませんし、10割なら諦めもつく。6割は、素人目には十分に見えて、よく見ると粗が見える感じの、ちょうど厄介なラインです。そして残念なことに、世の中の大半の場面は6割で十分だったようです。残念で仕方がありません。\u003C/p>\u003Cp>極端な話、LLMの出力する任意の成果物は単なるパターンマッチに過ぎません。確率的に尤もらしいトークンを並べているだけで、そこに理解と呼べるものがあるのかは怪しいところです。\u003C/p>\u003Cp>ただ、残念なことに、わたし達が知的作業と呼んでいたモノの多くは、そこまで崇高なものではなく、パターンマッチで代替可能でした。とりあえず動くものをつくる、目の前のエラーに場当たり的なパッチを当てる。そういうある種の短絡的な思考で解決できる問題は殆どLLMで解決可能になっているように感じています。認めたくないですがね。\u003C/p>\u003Cp>正直、コーディングや設計で現行のLLMに負ける気は更々ありません。が、これも今だけでしょう。そのうち負ける気がしています。というか、Fable 5相手ですら怪しいですからね。1年前のモデルには余裕を感じていたというのに。\u003C/p>\u003Cp>本当に負けた日には、わたしも「オデ、Fableノウデ、クウ。オデ、ツヨイエンジニアニ、ナル」とか言い出しかねません。まぁ、相手に腕は無いんですが。\u003C/p>\u003Cp>以前、LLMがどれだけ優れていても、つまらない人間はつまらないという記事を書きました。今回は、その矛先を自分に向けてみようと思います。\u003C/p>\u003Cp>私は幼い頃から気持ちの悪いコンピュータのオタクでした。それ自体はただただ気持ち悪いだけなのでいいのですが、いつの日からか、技術は好奇心の対象というより、無価値な自分を価値あるものに見せるための粉飾の材料になっていたようにも思います。技術の話をしている間だけは、何者かでいられるためです。\u003C/p>\u003Cp>念のため書いておくと、これは私が特別高度な事を理解していることを示唆するものではありません。\u003C/p>\u003Cp>とは言え、世の中の大半は(詳しいとされている人でさえ)基礎的な部分を碌に理解していません。TCPの輻輳制御を説明できないままインフラを語り、ベジェの制御点の意味も知らないままモーションを売っています。だから、尤もらしいことを言うだけで相対的に浮上できてしまう。ただそれは、周囲の無理解に支えられた非本質的な虚構でしかないとも思うのです。\u003C/p>\u003Cp>白状すると、私は自分を大きく見せようとする無知な人が苦手です。それは私自身がそういう側面を持ち合わせているからなのかもしれませんし、そうではないのかもしれません。実際のところ、周りが私のことをどう思っているかなんて知りようがないので、どちらとも言えないのですが。\u003C/p>\u003Cp>そして今、尤もらしいことを言う能力なら人間よりよほど上手い計算機が現れてしまいました。虚構で嵩上げしていた部分から順に価値が剥がれ落ちていきます。これは困ったことになりました。\u003C/p>\u003Cp>では、何が残るのでしょうか。\u003C/p>\u003Cp>私は、分からないことを分からないと自分で認識して、批判的に内省できることだと考えています。視座が高い話をしたいわけではありません。むしろ逆で、それすら出来ないメタ認知で、現代社会に適応できるのか？という純粋な疑問です。\u003C/p>\u003Cp>LLMは自分が何を分かっていないのかを分かっていません。知識の穴に差し掛かっても速度を落とさず、同じ自信で虚偽を出力してくる。あれだけ流暢に喋る計算機が、自分の限界の認識だけは絶望的に下手なわけです。裏を返せば、自分の理解の輪郭を把握していること, どこまでが検証済みでどこからが受け売りかを区別できること, 分からないと言えること。今のところ、ここあたりだけは辛うじて人間の役割として残っています。\u003C/p>\u003Cp>むしろ、それを持たない人間には価値がないので、LLMでいいです。分かっていないことを分かっていないまま断言する人間は、品質でも再現性でもコストでもその辺の計算機に劣ります。存在意義がありません。\u003C/p>\u003Cp>偉そうなことを書きましたが、真っ先に価値が剥がれる側は多分わたしです。コーディングで負ける日が来たとき、身の程を知る知性とやらが飯の種になる保証はどこにもありません。\u003C/p>\u003Cp>とはいえ、他にやれることも特に思いつかないので、当面は自分がどこから分かっていないのかの確認だけ続けることになりそうです。それすら粉飾の一部だと言われたら、返す言葉もありませんが。\u003C/p>",[12,18],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},"wia4slp55q","2025-04-28T07:14:14.541Z","2025-12-03T16:08:13.798Z","thoughts","独り言",{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},"qfey3yw0z1","2025-04-29T08:44:41.687Z","2025-12-03T16:07:14.622Z","technology","テクノロジー",true,{"id":26,"createdAt":27,"updatedAt":28,"publishedAt":29,"revisedAt":28,"title":30,"content":31,"tags":32,"is_no_index":46},"hp0fd0z-gnnt","2026-07-04T12:48:34.820Z","2026-07-04T13:57:28.343Z","2026-07-04T13:11:31.078Z","塩漬けのMisskey v12を最新版にアップグレードする","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">先日、お友達の運営する2つのMisskeyインスタンスを、v12.119.1ベースのフォークから最新の2026.6.0ベースの\u003Ca href=\"https://github.com/lqvp/misskey-tempura\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">別フォーク(tempura)\u003C/a>へ移行しました。およそ3年半の塩漬けインスタンスです。\u003Cbr>バックアップはありませんでしたし、FWもなく、inbound全解放の状態でした。よく今まで無事だったなと。\u003C/p>\u003Cp style=\"text-align: start\">旧サーバはConoHa VPS上のUbuntu 22.04にMisskey install shell scriptで構築された環境でした。これをKAGOYA VPSのUbuntu 26.04へDocker構成で丸ごと引っ越します。DBはPGroonga入りのPostgreSQL18にし、\u003Ccode>files/\u003C/code>配下にあったメディア類はR2へ、デプロイはcompose-cdでGitOps化するところまでやりました。当エントリはその備忘録です。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h8aea7b1b68\">移行前と移行後\u003C/h1>\u003Ctable>\u003Ctbody>\u003Ctr>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>旧\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>新\u003C/p>\u003C/th>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>サーバ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>ConoHa VPS(Ubuntu 22.04)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>KAGOYA VPS(Ubuntu 26.04LTS)\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Misskey\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>v12.119.1フォーク(systemd)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>tempura 2.0.9(Docker)\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>DB\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PostgreSQL15\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PostgreSQL18.4+PGroonga\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>全文検索\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>無し\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>PGroonga\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>メディア\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>ローカル\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Cloudflare R2\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>リバースプロキシ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>nginx+certbot\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>Caddy+Cloudflare Origin証明書\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>バックアップ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>無し\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>R2へ1日2回\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>デプロイ\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>compose-cd\u003C/p>\u003C/td>\u003C/tr>\u003C/tbody>\u003C/table>\u003Cp style=\"text-align: start\">compose一式は、以前\u003Ca href=\"https://misskey.blue/\" target=\"_blank\" rel=\"noopener noreferrer\">misskey.blue\u003C/a>用に組んだ\u003Ca href=\"https://github.com/chan-mai/misskey.blue-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">provisionレポジトリ\u003C/a>を下敷きにしています。 概ね中身は\u003Ca href=\"https://mq1.dev/entry/krpvl5itbr9h\" target=\"_blank\" rel=\"noopener noreferrer\">以前書いた構築記事\u003C/a>のものです。\u003Cbr>成果物は\u003Ca href=\"https://github.com/miel-misskey/rochka.club-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">ここ\u003C/a>に置いてあります。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://github.com/miel-misskey/rochka.club-docker-provision\" target=\"_blank\" rel=\"noopener noreferrer\">\u003Cu>https://github.com/miel-misskey/rochka.club-docker-provision\u003C/u>\u003C/a>\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hd196191eab\">アップグレードの方針\u003C/h1>\u003Cp style=\"text-align: start\">フォークのまま多段アップグレードはしません。というより、できませんでした。\u003C/p>\u003Cp style=\"text-align: start\">旧サーバが使っていたv12.119.1ベースのフォークは元レポジトリ自体がすでに消滅していて、差分をたどって移行パスを検証するという選択肢がなかったためです。なのでDBをバニラ相当とみなし、上流の通常アップグレード手順にそのまま追従させて最新版まで上げてから、最後にtempuraフォーク差分を適用することにしました。\u003C/p>\u003Cp style=\"text-align: start\">手順としては、旧サーバで\u003Ccode>pg_dump\u003C/code>したものを新サーバのpostgres:15コンテナへ\u003Ccode>pg_restore\u003C/code>し、あとはappイメージのtagを差し替えて起動、マイグレーションの完走を見届けてまた次のtagへ。\u003C/p>\u003Cp style=\"text-align: start\">ここで\u003Ccode>docker compose exec -T\u003C/code>がstdinを消費することにハマりました。スクリプトを流し込む段階で、execが残りのスクリプトをstdinとして食ってしまい、以降のコマンドが実行されなかったためです。restoreやインデックス作成がエラーを出さずに飛ぶので気づきにくいですが、SQLは\u003Ccode>&lt; file\u003C/code>、参照系は\u003Ccode>&lt;/dev/null\u003C/code>で明示的にリダイレクトするといいでしょう。\u003C/p>\u003Cp style=\"text-align: start\">今回は、12.119.1 → 13.14.2 → 2023.12.2 → 2024.10.0 → 2025.1.0 → 2026.6.0 → tempura 2.0.9の順でアップグレードしました。最大の難所はv12→v13で、かなり大規模な差分が入っています。マイグレーション数でいうと350本前後、tempura独自の57本を足して最終的に400本ちょっとでした。 \u003C/p>\u003Cp style=\"text-align: start\">v12からの移行で苦しんだことも記憶に新しいですが、もう3年前なんですね、あれ。\u003C/p>\u003Cp style=\"text-align: start\">移行に際して、\u003Ccode>default.yml\u003C/code>の項目も幾分か変わっているのですが、id方式(aid)だけは全世代を通して変えないことが必要です。これはデータの整合性を担保するために必須です。\u003C/p>\u003Cp style=\"text-align: start\">また、PG15からPG18への移行はメジャーバージョン跨ぎなのでデータディレクトリの流用はできず、ここもdump/restoreで行いました。PGDATAが\u003Ccode>/var/lib/postgresql/18/docker\u003C/code>にあるので、親ディレクトリごとマウントすれば永続化できます。今回は合わせてPGroongaの導入も行っているので、\u003Ccode>CREATE EXTENSION pgroonga\u003C/code>して\u003Ccode>note.text\u003C/code>にインデックスを張り、\u003Ccode>fulltextSearch: sqlPgroonga\u003C/code>を有効にしました。v12には無かった全文検索が使えるようになりました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h19474baa06\">メディア移行とMIME\u003C/h1>\u003Cp style=\"text-align: start\">\u003Ccode>drive_file\u003C/code>は53,534行ありましたが、ローカルに実体を持つのは4,007件だけで、残り49,527件はリモートのリンク参照でした。ファイルそのものは9,564個(原本4,007+サムネイル3,898+webpublic1,659)で計8.8GiBほどありました。これらをまとめて旧サーバからrcloneでR2へ直接上げました。\u003C/p>\u003Cp style=\"text-align: start\">DB側の既存メディアのURL書き換えは、\u003C/p>\u003Cpre>\u003Ccode class=\"language-sql\">UPDATE drive_file SET url = replace(url, &apos;https://&lt;domain&gt;/files/&apos;, &apos;https://media.&lt;domain&gt;/local/&apos;) WHERE ...;\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">で行い、空のフィールドは空のまま残るので404は出ません。非空URLの合計(4,007+3,898+1,659=9,564)が物理ファイル数とぴったり一致したことを確認しました。\u003C/p>\u003Cp style=\"text-align: start\">また、リモートには\u003Ccode>files/\u003C/code>配下のファイルを参照するデータが残っていることを考慮し、アプリケーションの前段に置いているCaddyでリダイレクトする経路をあわせて用意しています。\u003C/p>\u003Cpre>\u003Ccode>example.com {\n\ttls /etc/ssl/certs/certificate.pem /etc/ssl/private/key.pem\n\tencode gzip\n\theader /assets Cache-Control &quot;public, max-age=31536000, immutable&quot;\n\tfile_server\n\n\tlog {\n\t\toutput file /var/log/caddy/access.log {\n\t\t\troll_size 500mb\n\t\t\troll_keep 10\n\t\t}\n\t\tformat json {\n\t\t\ttime_format iso8601\n\t\t}\n\t}\n\n\t# 旧メディアURLをR2へ\n\t@legacyfiles path_regexp legacyfiles ^/files/(.*)$\n\tredir @legacyfiles https://media.example.com/local/{re.legacyfiles.1} permanent\n\n\treverse_proxy app:3000 {\n\t\theader_up X-Real-IP {header.CF-Connecting-IP}\n\t\theader_up X-Forwarded-For {header.CF-Connecting-IP}\n\t\theader_up X-Forwarded-Proto {scheme}\n\t\theader_up X-Forwarded-Host {host}\n\n\t\thealth_uri /\n\t\thealth_interval 10s\n\t\thealth_timeout 2s\n\t\thealth_status 200\n\t}\n\n\thandle_errors {\n\t\t# appコンテナ停止時などのバックエンドエラー\n\t\t@backend_down `{err.status_code} in [500, 501, 502, 503, 504, 522]`\n\t\thandle @backend_down {\n\t\t\t# /api系は外形監視のためメンテナンスを返さず本来のエラー(5xx)を通す\n\t\t\t@api path /api/*\n\t\t\thandle @api {\n\t\t\t\trespond {err.status_code}\n\t\t\t}\n\t\t\t# それ以外はメンテナンスページを200で返す\n\t\t\thandle {\n\t\t\t\treverse_proxy maintenance:80 {\n\t\t\t\t\thandle_response {\n\t\t\t\t\t\tcopy_response_headers\n\t\t\t\t\t\tcopy_response 200\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">R2への転送で一つ問題があり、Misskeyのローカルファイルはファイル名に拡張子がなく(accessKeyがそのままファイル名)、rcloneは拡張子からしかMIMEを判定しないため、全部\u003Ccode>application/octet-stream\u003C/code>でアップロードされていました。画像はブラウザのスニッフィングで表示されてしまうため気づきにくいのですが、動画がインライン再生できない等の問題が発生します。 修正として、S3のCopyObject(metadata REPLACE)で、Content-Typeだけ貼り替えました。\u003Ccode>file --mime-type\u003C/code>の実判定値を9,564件流し込んで完了しました。ちなみに、並列に\u003Ccode>aws-cli\u003C/code>を立ち上げるやり方はメモリ2GiBの子には荷が重すぎたようでOOMしてしまいました。boto3+スレッドプールの単一プロセスでやるのが速くて安全そうです。\u003C/p>\u003Cp style=\"text-align: start\">余談ですが、アップロード中に止めようとして打った\u003Ccode>pkill -f &quot;rclone copy&quot;\u003C/code>は、自分がsshで送ったコマンド文字列そのものにもマッチするようでセッションごと死んでしまい、exit 255が返ってきて数秒固まりました。\u003Ccode>pkill -x rclone\u003C/code>を使いましょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hc07c39fafc\">全員のアイコンがidenticonになる\u003C/h1>\u003Cp style=\"text-align: start\">v12からv13へのアップグレードで、既存ユーザの\u003Ccode>avatarUrl\u003C/code>/\u003Ccode>bannerUrl\u003C/code>がnullになりました。Misskeyの挙動として、identiconにフォールバックされるので、移行直後は全ユーザーのアイコンがidenticon表示になってしまいました。失敗したのかとちょっと焦りましたね。\u003C/p>\u003Cp style=\"text-align: start\">解決策として、\u003Ccode>drive_file\u003C/code>自体は無事(なはず)なので、そこから再構築しました。\u003C/p>\u003Cpre>\u003Ccode class=\"language-sql\">UPDATE &quot;user&quot; SET &quot;avatarUrl&quot; = COALESCE(NULLIF(f.&quot;webpublicUrl&quot;, &apos;&apos;), f.&quot;url&quot;) FROM drive_file f WHERE ...;\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">これで9,507人のアバターと7,238件のバナーが正常に表示されます。\u003Cbr>注意点として、ユーザ情報はin-memoryでキャッシュされているので、DBを直しただけでは反映されませんので、Misskeyのアプリケーション本体を再起動するところまでやりましょう。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、絵文字も似たような問題を踏んでいて、\u003Ccode>emoji.publicUrl\u003C/code>が旧URLのまま残り、ローカルの絵文字だけダミー画像にフォールバックしていました。URLを\u003Ccode>drive_file\u003C/code>と同じ直R2形式に置換し、Redisの\u003Ccode>&lt;domain&gt;:singlecache:localEmojis\u003C/code>を消して再起動することで事なきを得ました。 ここで安直に\u003Ccode>FLUSHALL\u003C/code>してしまうとジョブキューごと消えてしまうのでやってはいけません。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h61e6f6ae5a\">管理者がいない\u003C/h1>\u003Cp style=\"text-align: start\">今回利用しているフォークのtempuraでは、管理者/モデレータの判定を\u003Ccode>permissionGroup\u003C/code>という独自カラム(Admin/MainModerator/Normal/Community)で行っており、バニラにある\u003Ccode>isAdministrator\u003C/code>のbool値を見ていません。管理者ロールをDBで作って割り当てても、\u003Ccode>permissionGroup=&apos;Admin&apos;\u003C/code>でなければ権限は付かないことになります。\u003C/p>\u003Cp style=\"text-align: start\">さらに、v12からの移行では\u003Ccode>user.isAdmin\u003C/code>がロールに変換されません。よって、移行後のインスタンスには管理者が存在しないインスタンスが完成してしまいます。\u003C/p>\u003Cp style=\"text-align: start\">対応として、DBで管理者ロールを手組みしました。IDはaid形式(タイムスタンプのbase36を8桁+ランダム2桁)を自前で用意し、\u003Ccode>role_assignment\u003C/code>で割り当てて、ロールキャッシュ解消のためapp再起動。これで事なきを得ました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hcb82afc914\">横展開\u003C/h1>\u003Cp style=\"text-align: start\">同じ手順で2台目も移行しました。1台目のレポジトリをコピーしてfindとsedでドメインとレポジトリ名を置換し、\u003Ccode>compose.yaml\u003C/code>を初期状態(postgres:15+バニラ)に戻してから、同じ手順をもう一度たどるだけで、データ規模が小さかったこともあり、あっさりと終わりました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey.blue/notes/01KWHYKPNCBKFFHH66VA7D1Z8T\">https://misskey.blue/notes/01KWHYKPNCBKFFHH66VA7D1Z8T\u003C/a>\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">移行結果を確認しておきます。1台目でいうとnotes 189,545、users 9,841、drive_file 53,534。移行の前後で1件も欠けていません(usersだけ+1ですが、これはインスタンスactorが追加されたぶんなので正常です)。\u003Cbr>3年半塩漬けだったデータを、そのまま現行環境へ載せ替えられました。\u003C/p>\u003Cp style=\"text-align: start\">同じようなことを試みる誰かの参考になれば幸いです。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>",[33,39,40],{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},"vmhb23sq4","2025-07-29T12:56:07.884Z","2025-12-03T16:06:19.140Z","misskey","Misskey",{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":41,"createdAt":42,"updatedAt":43,"publishedAt":42,"revisedAt":43,"slug":44,"name":45},"4oy2jc8mdg","2025-04-28T07:14:01.179Z","2025-12-03T16:08:24.495Z","diary","日記",false,{"id":48,"createdAt":49,"updatedAt":50,"publishedAt":50,"revisedAt":50,"title":51,"content":52,"tags":53,"is_no_index":46},"ojqjorlb0n","2026-06-22T20:52:47.034Z","2026-06-22T20:54:10.257Z","つまらないのは","\u003Cp>最近よく思うのですが、世の中のほとんどのことは、数値に落とし込むことも、白か黒かで割り切ることもできません。かけた労力と結果がきれいに比例するわけでもなく、こちらが何もしていなくても、時間が経つというだけで、多くのものは静かに劣化していきます。努力が分かりやすく報われることもありませんし、わたしという存在に、特別な意味が割り当てられているわけでもありません。これは別に悲観でもなんでもなく、ただそういうものなのだろう、という話です。\u003C/p>\u003Cp>そういう前提に立つと、人がどう生きているのかも、なんとなく見えてきます。みんな、分かりたいものだけを分かろうとして、分かる気の起きないものは、初めから理解の対象から外しています。世界が不平等で歪んでいても、主観と決めつけのまま、手近なところにある快楽を拾って生きていく。嫌いな相手は一生嫌い続けるし、大事にしたいものだけを大事にする。わたしは、これ自体を否定するつもりはありません。むしろ、いっそ正直で良いとすら思っています。何より、わたし自身がそうであるからです。\u003C/p>\u003Cp>大多数の人間は、何をしたところで何者かになることなどありません。にもかかわらず、SNSという与えられた餌で勘違いをして、自分にも言いたいことがある、という顔で自己主張を始めてしまう。あれはどうにも不健全だなと、眺めるたびに思います。\u003C/p>\u003Cp>素人だろうと、専門家だろうと、当事者だろうと、一個人の意見や主張なんて、世の中の側からすればどうでもいいことです。何かを思うのは構いません。ただ、本当は誰にも言わない方がいいのだろうと、思うようになりました。\u003C/p>\u003Cp>とりわけそう感じるのが、生き方とか、正しさとか、社会はこうあるべきだとか、その類の話です。この手の話題は、語り始めるためのハードルが異様に低い。元手も予習もいらず、誰でも今日から語り部になれてしまう。しかし、本来は、突き詰めて学んだ人ほど軽々しくは口を開けなくなるはずのものだと思います。知れば知るほど、これは自分が断言していいことなのか慎重になる。だとすれば、いつまでも淀みなく喋っていられること自体が、どこか怪しい。中身の薄さを、言葉の量で覆い隠しているだけではないか。楽な方へ逃げているだけで、実態が伴っていないのではないか。\u003C/p>\u003Cp>そういうのは結局、逃げであって、何も実りません。だからもっと、目の前で起こっている事象そのものについて話したいのです。誰が言ったかでも、どう感じたかでもなく、いま現に起きている事柄について。その方がずっと健全だと分かっています。\u003C/p>\u003Cp>と、ここまで書いて気づくのですが。\u003C/p>\u003Cp>近年のインターネットはつまらない、と、わたしは日々のように吐き捨てています。けれど結局のところ、つまらないのは、わたし自身の方なのかもしれません。中身がないのを人のせいにして、こうして長々と書き連ねて、それで少しだけ満たされた気になっている。たぶん、そういうことなのでしょう。\u003C/p>",[54],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":56,"createdAt":57,"updatedAt":58,"publishedAt":59,"revisedAt":58,"title":60,"content":61,"tags":62,"is_no_index":46},"oy69jxht12","2026-06-19T18:38:58.987Z","2026-06-26T14:48:16.179Z","2026-06-19T18:48:10.255Z","Hide My Emailのドメインが変わるので、また悩んでいる","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前、\u003Ca href=\"https://mq1.dev/entry/svgkndwg3ng9\">独自ドメインでのメールアドレス運用をやめ、Hide My Emailに移行した話\u003C/a>という記事を書きました。「自前運用もSESも全部やめて全てをHide My Emailに寄せたよ〜」という話です。\u003C/p>\u003Cp>で、そのHide My Emailなんですが、先日、生成されるアドレスのドメインが\u003Ccode>@icloud.com\u003C/code>から\u003Ccode>@private.icloud.com\u003C/code>に変わると発表されました。すでに発行済みのアドレスは引き続き転送されるらしいので、過去の登録が消えてしまうわけではないのですが、これから作るぶんが別ドメインになる時点で、わたしにとっての価値の大半は消し飛びます。\u003C/p>\u003Cp>\u003Ca href=\"https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://developer.apple.com/news/?id=sus6t6ab&amp;7194ef805fa2d04b0f7e8c9521f97343\u003C/a>\u003C/p>\u003Cp>さて、困りました。\u003C/p>\u003Ch1 id=\"ha05bbba395\">なにが困るのか\u003C/h1>\u003Cp>わたしがHide My Emailを愛用していた理由は、大きく二つあります。\u003C/p>\u003Cp>プライマリのアドレスと紐付けずにサービスごとに使い分けられること、そして\u003Ccode>icloud.com\u003C/code>のドメインパワーに肖れることです。\u003C/p>\u003Cp>特に後者が大きくて。\u003Cbr>そもそもHide My Emailを嬉しく思っていたのは、生成されるアドレスが普通のiCloudユーザと見分けがつかなかったからでした。受け手からすればただのiCloudユーザーにしか見えないので、後述する特定の大手ドメインしか受け付けないみたいな邪悪な実装も平然と通過できていました。それが\u003Ccode>@private.icloud.com\u003C/code>になると、受け手がHide My Emailを判別できるようになってしまいます。\u003C/p>\u003Cp>要するに、機能が成立していた前提をApple自身が手放しにきているわけです。プライバシ機能を名乗っておいてこれは、なかなかのものだなと思います。\u003C/p>\u003Cp>で、世の中には、GmailとiCloud(国内だとヘンテコ仕様なキャリアメールなんかも)以外のドメインを弾くようになっているサービスがそこそこあります。\u003Cs>Pi○tLinkなんかがそうですね。\u003C/s>こういう邪悪な実装のところでは、\u003Ccode>icloud.com\u003C/code>が使えること自体が最大にして唯一の利点でした。正直、わたしはこの一点のためだけにiCloud+へ課金していたと言っても過言ではありません。\u003C/p>\u003Cp>プライバシがどうとかいう高尚な理由ではなく、純粋に弾かれないための月額だったので、ここがなくなってしまうのはとても苦しい。\u003C/p>\u003Ch1 id=\"hf58740672c\">そもそも独自ドメインのメールって旨味あるの\u003C/h1>\u003Cp>「じゃあ独自ドメインに戻せば」という話になりそうですが、わたしはこのご時世に個人で独自ドメインのメールを持つ旨味、あんまりないと思っています。理由は二つ。\u003C/p>\u003Cp>一つは、メールサーバを自前で持つハードルが高いこと。\u003Cbr>ここで言うハードルは、構築の手間のことではありません。安定して長期運用できること、そしてレピュテーションを健全に保ち続けられること。実際のところ、メールサーバを立てるだけなら誰でもできるのですが、問題はそこから先で、送信ドメイン認証を整えて、IPを腐らせないように気を遣って、ある日突然どこかにブロックされても対処し続ける維持のほうが本体なんですよね。一度評判を落とすと戻すのも大変だし、これを個人で延々とやる価値があるかというと、正直微妙です。\u003C/p>\u003Cp>そもそもASN単位でブロックリスト入りすることも往々にしてあるので、根本的解決を図るにはASNを取得しIP割当を受けるしかない気がします。あまりにも不毛です。\u003C/p>\u003Cp>もう一つは、結局外部のプロバイダに乗せるなら独自ドメインの旨味は薄いこと。\u003Cbr>SESなりWorkspaceなりに任せるなら、配送やレピュテーションの面倒は向こうが見てくれる代わりに、自分が握ってるのはドメイン名といざとなれば乗り換えられる安心感くらいになります。それはそれで価値がないこともないのですが、Hide My Emailの大手のドメインに乗れることとはそもそも別の話です。\u003C/p>\u003Cp>もちろん利点もあって、スパムが来たときにどのサービスがお漏らしたか分かるとか、普段使いのアドレスを隠せるとか、管理が幾分か楽になるとか。でも正直、どれも過去のHide My Emailを超えることはないように思います。\u003C/p>\u003Ch1 id=\"hba13ad79dc\">Gmailのサブアドレスも微妙\u003C/h1>\u003Cp>Gmailのエイリアス(※1)を使う手もあります。\u003C/p>\u003Cp>ただこれ、同一人物への到達性を保証しなくていいスパムであれば、local partのuser部だけ抽出して(厳密ではないにせよ)送れてしまうんですよね。\u003Cs>少なくとも私ならそういう実装をしますし、多くの人がそう考えると思います。\u003C/s>Separator character sequence以降を捨てれば届いてしまうので、使い捨てアドレスとしては心許ない。かなり微妙。\u003C/p>\u003Ch1 id=\"h287541f080\">で、結局どうするか決まってない\u003C/h1>\u003Cp>整理すると、わたしが欲しいのは三つです。プライマリのアドレスを隠せること、サービスごとに分けられること、そして邪悪な実装を通れること。最初の二つは代替がいくらでもあるのですが、問題は三つめで、これを満たせる選択肢が驚くほど見つかりません。\u003C/p>\u003Cp>一応、候補は一通り眺めてみました。\u003C/p>\u003Cp>\u003Ca href=\"https://addy.io/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">addy.io\u003C/a>や\u003Ca href=\"https://simplelogin.io/ja/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">SimpleLogin\u003C/a>のようなエイリアスサービスは、マスキングも使い分けも完璧。しかし、相手に渡すアドレスは結局そのサービス自身のドメインになるので、\u003Ccode>@addy.io\u003C/code>とか\u003Ccode>@simplelogin.io\u003C/code>をGmail/iCloud/ヘンテコキャリアメールしか通さない許可リストが受け入れてくれるわけがありません。ついでにSimpleLoginはProton傘下で、わたしはProtonが好きではないので却下。\u003C/p>\u003Cp>独自ドメインを自前運用したりWorkspaceに載せたりする手も、同じ壁に当たります。受け手が見てるのはドメイン名の文字列であって、誰がホストしてるかではないため、どれだけ真っ当に運用されてようと、\u003Ccode>icloud.com\u003C/code>でも\u003Ccode>gmail.com\u003C/code>でもない以上、リテラル照合の前では無力です。Googleのインフラに乗せようが、自分のドメインである事実は変わらないので。\u003C/p>\u003Cp>iCloudの素のエイリアス(Hide My Emailではなく、\u003Ccode>@icloud.com\u003C/code>を最大3つ作れるやつ)は、ドメインが本物の\u003Ccode>icloud.com\u003C/code>のままなので許可リストは通ります。これはうれしい。しかし、3つしか作れないうえ、3つ埋まった状態で1つ消すと次を作るまで7日待たされるようで、使い捨てとして回すには微妙です。\u003C/p>\u003Cp>規約上どうかは知りませんが、転送専用のGmailアカウントを量産する手も思いつきました。\u003Cs>(思いついただけでやってないので叩かないでください)\u003C/s>これなら渡すのは正真正銘の\u003Ccode>@gmail.com\u003C/code>で許可リストも確実に通るし、数も稼げそうです。しかし、アカウントごとに電話番号認証だの複数アカウントの取り回しだのが付いてくる上、Googleの機嫌次第でまとめて凍結される可能性も拭えず、得られる体験のわりに管理コストとリスクが釣り合っていません。無念。\u003C/p>\u003Cp>という具合に、どれを取っても過去のHide My Emailには届かないんです。結局わたしが失おうとしてるのは、「大手のドメインに、ほぼ無限に、片手間で乗れる」という、よく考えるとかなり贅沢な状態だったんだなと。\u003Cbr>しかもApple側が仕様としてやめると言ってる以上、こっちの工夫でどうこうできる話でもなく。すごく普通に困ってます。\u003C/p>\u003Cp>そもそもみなさん、メールアドレスの運用ってどうしてるんでしょう？\u003Cbr>いい感じの方法があったら本当に教えてほしいです。\u003C/p>\u003Cp>\u003C/p>\u003Cp>\u003C/p>\u003Chr>\u003Cp>※1 RFC 5233のsubaddressingは、Separator character sequence + Detailであって、これがエイリアスでないことは理解しています。が、(腹立たしいことに)慣習上そう呼ばれることが多い(らしい)のでここでもそう呼んでます。\u003C/p>\u003Cp>蛇足ですが、&quot;Separator character sequence&quot;という言い方をすると、何らかの形で明確に分離された構造になっているように聞こえますが、実際にRFCが言ってるのは「UserにDetailを加えたアドレスをUserにルーティングできる」くらいのことでしかなくて、RFC 5321/5322が「local partの解釈は受け手のソフトウェア次第」ってスタンスなのに対して、RFC 5233はlocal partの形式の例として「local part=userってわけじゃなくてぇ」みたいな話をしてるに過ぎません。広げてるように見えて制限してる規格かと思いきや、その実なにも制限してない用語と用例が出されてるだけのものなので誤解しないようにしてくださいね。\u003C/p>\u003Cp>そもそもエイリアスは原初から全く別の機能の名称として存在してるので、この呼び方にはずっと不満があります。\u003C/p>",[63,64,70,71],{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":65,"createdAt":66,"updatedAt":67,"publishedAt":66,"revisedAt":67,"slug":68,"name":69},"fwj_nwhj1-s","2025-04-30T04:55:28.953Z","2025-12-03T16:07:03.591Z","server","サーバー",{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":41,"createdAt":42,"updatedAt":43,"publishedAt":42,"revisedAt":43,"slug":44,"name":45},{"id":73,"createdAt":74,"updatedAt":75,"publishedAt":76,"revisedAt":75,"title":77,"content":78,"tags":79,"is_no_index":46},"p7zftgqr_ht","2026-06-03T17:50:57.739Z","2026-06-04T08:52:49.895Z","2026-06-03T18:04:39.359Z","商業登記電子証明書(.p12)まわりの覚書","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">最近、商業登記電子証明書の証明書ファイル本体(.p12)を扱う機会がありました。かなり苦しんだので、触っていく中で見つけた諸々を備忘録として書き残しておきます。\u003C/p>\u003Cp style=\"text-align: start\">なお、実物には当然ながら法人のクレデンシャルが含まれているので、商号、代表者氏名、シリアル番号、Subject Key Identifierといった同定可能な値はすべて伏せています。本エントリはあくまで仕様の話です。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h4d904280a0\">.p12コンテナを開ける\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書は、登記・供託オンライン申請システムからPKCS#12コンテナとしてダウンロードされます。OpenSSL 3系で開く場合は\u003Ccode>-legacy\u003C/code>が必要です。\u003C/p>\u003Cpre>\u003Ccode class=\"language-bash\">openssl pkcs12 -in hogefuga.p12 -info -noout -legacy\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">MAC=SHA-1、暗号化=3DES-CBC、Iteration=2000という構成で、2026年の基準では古風な部類です。\u003C/p>\u003Cp style=\"text-align: start\">なお、このPKCS#12の選択は法務省告示第543号の規定ではなく、登記・供託オンライン申請システム側の配布フォーマットの都合です。告示はX.509証明書のフィールド構造とCMP発行プロトコルだけを規定していて、利用者への手渡し方は何も書かれていません。仕様ではなく運用、ということは頭に入れておくとよさそうです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hb2cb04a84e\">中の証明書\u003C/h1>\u003Cp style=\"text-align: start\">p12を解体すると、秘密鍵1個と証明書2枚(エンドエンティティ+中間CA)が出てきます。ルートCAは入っていないので、検証側で別途取得する必要があります。\u003C/p>\u003Cp style=\"text-align: start\">エンドエンティティ証明書の基本情報はわりと普通です。\u003C/p>\u003Cul>\u003Cli>X.509 v3\u003C/li>\u003Cli>署名: sha256WithRSAEncryption\u003C/li>\u003Cli>公開鍵: RSA 2048bit\u003C/li>\u003Cli>Issuer: C=JP, O=Japanese Government, OU=Ministry of Justice, CN=Registrar of Tokyo Legal Affairs Bureau\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">問題はSubject DNで、\u003C/p>\u003Cpre>\u003Ccode>C=JP\nO=MOJ No.XXXXXXXXXXXX\nCN=0402010000001\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">Oが\u003Ccode>Japanese Government\u003C/code>ではなく\u003Ccode>MOJ No.{会社法人番号}\u003C/code>。\u003Cbr>CNは基本的に\u003Ccode>{役員番号}\u003C/code>で、ローマ字氏名が登録されている場合のみ\u003Ccode>{役員番号}-{氏名のローマ字}\u003C/code>の形式になります。私が触った範囲ではいずれもハイフンなしの役員番号のみでした。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、これを普通のクライアント証明書の感覚で扱うと、O属性を法人名だと思い込んでパースしているライブラリが、ニッコリ笑顔で会社法人番号を法人名として表示してくれます。\u003C/p>\u003Cp style=\"text-align: start\">法人名そのものは、Subject DNではなく証明書の拡張領域に格納されています。私はこれで数時間を溶かしました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hd5b6d68703\">本体は1.2.392.100300.1.1.3\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書のすべてが詰まっているのが、法務省独自OID\u003Ccode>1.2.392.100300.1.1.3\u003C/code>です。中身はこんなASN.1構造になっています。\u003C/p>\u003Cpre>\u003Ccode class=\"language-asn1\">RegisteredCorporationInfoSyntax ::= SEQUENCE {\n    corporateName              [0] DirectoryString,  // 商号\n    registeredNumber           [1] PrintableString,  // 会社法人等番号\n    corporateAddress           [2] DirectoryString,  // 本店所在地\n    representativeDirectorName  [3] DirectoryString,  // 代表者氏名\n    representativeDirectorTitle [4] DirectoryString,  // 代表者役職\n    registryOffice             [6] DirectoryString   // 登記所\n}\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">商号も本店も代表者氏名も役職も法人番号も登記所も、全部証明書本体に埋め込まれているわけです。登記簿照会を都度叩かなくとも、証明書を検証するだけで法人実体を照会できます。これは商業登記電子証明書独特の特徴なようで、海外の一般的なクライアント証明書とは目的がそもそも違います。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、お気づきの方もいらっしゃるかもしれませんが、タグが\u003Ccode>[0][1][2][3][4][6]\u003C/code>と並んでいて、\u003Ccode>[5]\u003C/code>が欠番になっています。告示付録2のASN.1モジュールにも\u003Ccode>[5]\u003C/code>は定義されていません。\u003C/p>\u003Cp style=\"text-align: start\">何だったんでしょうね、これ。予約とすら書かれていないあたり、運用初期に検討されて消えたフィールドなのかもしれませんが、想像の域を出ません。\u003C/p>\u003Cp style=\"text-align: start\">ついでに、もう2つの独自拡張も紹介しておきます。\u003C/p>\u003Cul>\u003Cli>1.2.392.100300.1.1.1: 日本語のUserNotice(「この証明書は、商業登記法その他の関係法令等に基づき発行されたものです。」)\u003C/li>\u003Cli>1.2.392.100300.1.1.2: 東京法務局登記官 というUTF8文字列。発行者の役職そのもの\u003C/li>\u003C/ul>\u003Cp style=\"text-align: start\">実は標準の\u003Ccode>certificatePolicies\u003C/code>(\u003Ccode>2.5.29.32\u003C/code>)拡張内にも英語のUserNoticeが入っていて、英文と和文が並存しています。同じ趣旨を文字種を変えて二重格納するというのは、ある意味で律儀ですが、実装側からすると少し迷惑な気もします。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h14c781f17c\">発行者の扱い\u003C/h1>\u003Cp style=\"text-align: start\">これは仕様を眺めていて知ったのですが、商業登記電子証明書の発行者は申請者がどこの法務局に申請しても\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>となり、東京法務局登記官で固定なようです。\u003C/p>\u003Cp style=\"text-align: start\">申請者の管轄登記所(例えば長崎地方法務局とか)は、Issuerフィールドではなく独自拡張の\u003Ccode>registryOffice\u003C/code>に入ります。\u003C/p>\u003Cp style=\"text-align: start\">普通のPKIだと「発行者=申請を受け付けた組織」と素朴に対応していることが多いので、気をつけておくといいでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7f21a26569\">中間CAが並存\u003C/h1>\u003Cp style=\"text-align: start\">ここもハマる方が多そうですが、商業登記認証局(CRCA1)の中間CA証明書は2世代運用されています。\u003C/p>\u003Ctable>\u003Ctbody>\u003Ctr>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>世代\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>有効期間\u003C/p>\u003C/th>\u003Cth colspan=\"1\" rowspan=\"1\">\u003Cp>秘密鍵使用期間\u003C/p>\u003C/th>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>旧CRCA(2022)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>6年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>3年\u003C/p>\u003C/td>\u003C/tr>\u003Ctr>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>新CRCA(2026)\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>10年\u003C/p>\u003C/td>\u003Ctd colspan=\"1\" rowspan=\"1\">\u003Cp>5年\u003C/p>\u003C/td>\u003C/tr>\u003C/tbody>\u003C/table>\u003Cp style=\"text-align: start\">両世代ともCommon Nameは同じ\u003Ccode>Registrar of Tokyo Legal Affairs Bureau\u003C/code>なので、CNだけでは識別できません。\u003Ca href=\"https://crca1.moj.go.jp/toukikan.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">シリアル番号\u003C/a>かSubject Key Identifierで判別すべきです。\u003C/p>\u003Cp style=\"text-align: start\">ちなみに、告示第543号の本文では登記官証明書は「72ヶ月/36ヶ月」、つまり6年/3年と規定されています。私が実際に触ったいくつかの証明書に含まれるCRCAは規定の倍近くに延長されていたので、告示の改正があったか、関係法令で別途上書きされているはずですが、本記事執筆時点では出典を特定できていません。詳しい方、誰か教えてください。\u003C/p>\u003Cp style=\"text-align: start\">それと、新世代では\u003Ccode>keyUsage\u003C/code>が\u003Ccode>critical\u003C/code>で付くようになっていたり、CRL Distribution Points拡張が追加されていたりと、告示の最低要件を超えた拡張が行われているようです。告示で定められているのは最低限ラインで、運用側のCP/CPSで上乗せされる感じなのかもしれません。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h7b1b74ce46\">失効確認\u003C/h1>\u003Cp style=\"text-align: start\">失効確認はAIA(\u003Ccode>1.3.6.1.5.5.7.48.1\u003C/code>)に書かれているOCSP URLを叩きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-plaintext\">http://crca.moj.go.jp/bin/dcwcgi/DC_HUSR/cert/cert\u003C/code>\u003C/pre>\u003Cp style=\"text-align: start\">新世代CAではCRLのURIも追加されていて、\u003Ccode>http://crca1.moj.go.jp/certificateRevocationList.crl\u003C/code>から取得できます。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に面白いのが、商業登記電子証明書には失効だけでなく休止届という独自概念があることです。CRLではエントリの\u003Ccode>reasonCode\u003C/code>(\u003Ccode>2.5.29.21\u003C/code>)に\u003Ccode>certificateHold (6)\u003C/code>が入り、OCSPでは\u003Ccode>CertStatus\u003C/code>が\u003Ccode>revoked\u003C/code>で返り、その\u003Ccode>revokedInfo.revocationReason\u003C/code>に\u003Ccode>certificateHold (6)\u003C/code>が入ります。代表者が交代しそうだとか、本店移転の登記が走るだとか、そのような一時停止のためのものです。商業登記電子証明書ならではの特徴と言えるでしょう。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">商業登記電子証明書を一言でまとめると、X.509のガワを被った登記簿の抜粋です。証明書としての構造はRFC 5280に準拠しつつ、肝心の登記情報は法務省独自OIDの拡張領域に格納されていて、PKIライブラリで素直に検証すると半分しか機能を引き出せません。\u003C/p>\u003Cp style=\"text-align: start\">ところで、平成26年の告示は本体署名を\u003Ccode>sha1WithRSAEncryption\u003C/code>から\u003Ccode>sha256WithRSAEncryption\u003C/code>に切り替えるアップデート(※1)だったわけですが、p12コンテナのMACが今もSHA-1なのは正直なんとも言えない味わいがあります。中身だけ新しくしてガワが旧式というのは、行政あるあるな気もしますが、いつかはAES+PBKDF2あたりに刷新してほしい気持ちが若干あります。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>\u003Cp style=\"text-align: start\">\u003C/p>\u003Cp style=\"text-align: start\">※1: \u003Ccode>subjectKeyIdentifier\u003C/code>や\u003Ccode>authorityKeyIdentifier\u003C/code>の補助用途では今もSHA-1が使われています\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h3de35099b3\">参考\u003C/h1>\u003Cul>\u003Cli>\u003Ca href=\"https://www.digital.go.jp/assets/contents/node/basic_page/field_ref_resources/d12bde7e-a950-493b-987c-0f8d4bbd1b6b/20211228_notice_article_06.pdf\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">法務省告示第543号(平成26年12月12日)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/339M50000010023\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記規則(昭和39年法務省令第23号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://laws.e-gov.go.jp/law/338AC0000000125\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記法(昭和38年法律第125号)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.touki-kyoutaku-online.moj.go.jp/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">登記・供託オンライン申請システム\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://www.moj.go.jp/MINJI/minji06_00028.html\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">商業登記に基づく電子認証制度(法務省)\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc5280\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 5280 Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc7292\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 7292 PKCS #12: Personal Information Exchange Syntax v1.1\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc6960\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 6960 X.509 Internet Public Key Infrastructure Online Certificate Status Protocol - OCSP\u003C/a>\u003C/li>\u003Cli>\u003Ca href=\"https://datatracker.ietf.org/doc/html/rfc4210\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">RFC 4210 Internet X.509 Public Key Infrastructure Certificate Management Protocol (CMP)\u003C/a>\u003C/li>\u003C/ul>\u003Cp>\u003C/p>",[80],{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":82,"createdAt":83,"updatedAt":84,"publishedAt":84,"revisedAt":84,"title":85,"content":86,"tags":87,"is_no_index":46},"j7zvrsp48lb","2026-05-04T22:43:43.362Z","2026-05-05T00:07:59.619Z","Tailscaleやめたい","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>Tailscaleはネットワークの知識がない人が使うものだと思っていて、内心ずっと冷笑しているのですが、悪い噂をあまり聞かないので本当に悲しいです。用途に応じて使い分けろという話なんだろうとは思います。思いますが、他人の環境にアクセスしたいときに「まずTailscaleを入れて〜」みたいなことを言われると本当に嫌な気持ちになります。\u003C/p>\u003Cp>\u003Cs>ちなみに私も使っているので、あまり人のことを言えた義理ではありません。\u003C/s>\u003C/p>\u003Cp>便利なのは認めます。\u003Ccode>tailscale up\u003C/code>一発で直接インターネットへの疎通性を持たないホストに外から入れるのは素直に便利ですし、ちょっとしたマネジメント用途にはすごく使えるものだと思います。ただ、この便利は、OSのリゾルバ, netfilter, routing table他諸々に対する全力の侵襲と引き換えに成立しています。問題は、その侵襲の質が悪いことで、本当にストレスが溜まります。\u003C/p>\u003Cp>本エントリでは、これまでTailscaleを使ってきた中で頭にきたことを書いていきます。既に修正されたものもありますし、自分の設定が悪いだけのものも混ざっているかもしれません。それでも踏んだ事実は事実なので、忘れないうちに文句を言っておくことにします。\u003C/p>\u003Ch1 id=\"h89f5d63c3c\">resolv.confの扱い\u003C/h1>\u003Cp>tailscaledは起動するたびに\u003Ccode>/etc/resolv.conf\u003C/code>を書き換え、MagicDNS(100.100.100.100)をnameserverの先頭に挿入します。これがsystemd-resolvedとの相性が破滅的に悪い。\u003C/p>\u003Cp>systemd-resolvedはfollower modeで起動すると\u003Ccode>/run/systemd/resolve/resolv.conf\u003C/code>を生成します。tailscaledが事前に挿した100.100.100.100がここに混ざり、それを起動時の状態として読み戻し、upstreamのDNSの一つだと誤認して自身へ向けてDNSクエリを延々と転送し始めます。結果、内部キューが埋まってDNSが無事に死にます。\u003C/p>\u003Cp>これはGitHub上にもAmazon Linux環境での事象が報告されていて、報告者がtailscaleの中の人。タイトルが｢DNS stops working, and everything is very sad｣。very sadなのはこっちなんですけどね。あなたは知っててなぜ直さないんです？？？\u003C/p>\u003Cp>検出ロジックも杜撰で、systemd-resolvedかどうかを \u003Ccode>resolv.conf\u003C/code>のシンボリックリンク先のファイル名だけで判定しています。\u003Ccode>/etc/resolv.conf\u003C/code>が\u003Ccode>stub-resolv.conf\u003C/code>ではなくlegacy側にリンクされている場合、systemd-resolvedの存在に気づかず、direct モードに落ちてしまいます。諸々の組み合わせ全部を正しくハンドルしようとして、当然のように全部カバーできていません。これについて、tailscale公式ブログが“The Sisyphean Task Of DNS Client Config on Linux”と銘打って解説していますが、タイトルで自虐している時点で察してほしいです。\u003C/p>\u003Ch1 id=\"h77d9c93ff7\">RFC違反のDNS実装\u003C/h1>\u003Cp>resolv.confの話については、LinuxのDNS事情が混沌としてるのである程度同情の余地があります。しかし、tailscaleのDNSプロキシ実装そのものがRFCに準拠できていないのはどうにもなりません。\u003C/p>\u003Cp>EDNSのOPTレコードを完全無視します。クライアントがUDPのbuffer sizeを512 byteと広告していても、690 byte返してきます。RFC 6891がresponder MUST NOT exceed the requestor&apos;s buffer sizeと書いているものを、堂々とMUST NOT違反します。Cloudflareの子はちゃんとTC bitセットしてTCPフォールバックを促すのに、tailscale DNSはそれすらしません。\u003C/p>\u003Cp>DNS Flag Day 2020違反もあって、1232 byteを超えてもtruncateせずIPフラグメンテーションを起こします。上流からTC bit付きで返ってきても自分自身がTCPフォールバックできません。\u003C/p>\u003Cp>おまけに、EDNS Client Subnetを勝手にstripして米国IPに置換する挙動もあります。日本から繋いでるのにCDNが米国エッジに飛ばしてきます。MagicDNSを通すだけでレイテンシが100ms乗ります。VPN通すと遅くなるVPN、本当になんなんですか。\u003C/p>\u003Cp>極めつけがEDNS有無でキャッシュキーが分かれるsplit-brain cacheです。digとnslookupで同じドメインに対して別々の結果が(永続的に)帰ってきます。うーん困った。\u003C/p>\u003Cp>ちなみに文句はまだまだあって、tailscaledは \u003Ccode>resolv.conf\u003C/code>のsearch domainは保持するくせに、\u003Ccode>options ndots:5\u003C/code>を完全に剥がす振る舞いをします。Kubernetes環境なんかだとこれが致命的で、Pod内\u003Ccode>ping kuard.default\u003C/code>がbad addressで死んだ記憶があります。\u003C/p>\u003Ch1 id=\"h6d76dc14aa\">100.64/10をハードコードで全部drop\u003C/h1>\u003Cp>これが一番頭に来てる話です。\u003C/p>\u003Cp>ご存知の通り、tailscaleはtailnetのレンジとして100.64.0.0/10を使っています。曰く、「ISP用に予約された帯だから他のネットワークとぶつからない」らしい。確かにRFC 6598の定める100.64/10はService Provider向けのShared Address Spaceで、RFC 1918のものとは区別されています。とはいえ、インターフェース間のNATを介する用途であれば使用可能と明記されており、実際そう使っている方も多いのではないでしょうか。\u003C/p>\u003Cp>10/8と172.16/12がVPNやKubernetes Pod CIDRやDocker bridgeで埋め尽くされてる現状、まともにアドレス計画を立てたネットワークが100.64/10に逃げるのは普通の選択です。うちもそうしてます。自宅も会社もそう。ISPだけが使えるという前提がそもそも成り立っていません。\u003C/p>\u003Cp>それで、tailscaleを起動するとts-inputチェーンに\u003Ccode>DROP all -- !tailscale0 * 100.64.0.0/10 0.0.0.0/0\u003C/code>が挿入されます。tailscale0以外から来た100.64/10宛のパケットを全部drop。こちらが普通に内部で使ってる100.64/10宛の通信をtailscaleが勝手に殺してきます。困りますね。\u003C/p>\u003Cp>しかもこのDROPルール、tailnet policyで自分の使う範囲を100.65.64.0/22のように狭く指定しても、生成されるルールは依然として100.64.0.0/10全域です。コードを見ると\u003Ccode>util/linuxfw/nftables_runner.go\u003C/code>の\u003Ccode>addDropCGNATRangeRule\u003C/code>と\u003Ccode>createDropOutgoingPacketFromCGNATRangeRuleWithTunname\u003C/code>がそれぞれ別の方法で同じ範囲をルール化していて、片方は文字列正規化、もう片方は生の\u003Ccode>netip.Prefix\u003C/code>を利用しているようで。実装が二箇所に分散してる時点で察してほしいです。\u003C/p>\u003Cp>ちなみに、｢100.100.0.0/20で社内SSH運用してたサーバにtailscaleをインストールしたら、無条件で100.64/10全域DROPルールが入ってSSHを含むLAN通信が全切断、リモートからアクセス不能｣という事例がissueに上がっています。\u003C/p>\u003Cp>\u003Ca href=\"https://github.com/tailscale/tailscale/issues/12829\" target=\"_blank\" rel=\"noopener noreferrer\">https://github.com/tailscale/tailscale/issues/12829\u003C/a>\u003C/p>\u003Cp>これ、2024年7月起票で現在もneeds-triageラベルのままopenです。triageもされてない。1年半以上。\u003C/p>\u003Cp>この回避策が\u003Ccode>--netfilter-mode=off\u003C/code>ですが、これはtailscale自身が公式ドキュメントでセキュリティリスクだと書いているフラグで、もうどうしようもないんじゃないの感があります。苦しい。\u003C/p>\u003Ch1 id=\"h729e032ab6\">iptablesの介入がすごく頭悪い\u003C/h1>\u003Cp>\u003Ca href=\"https://github.com/tailscale/tailscale/issues/320\" target=\"_blank\" rel=\"noopener noreferrer\">tailscaleの中の人が起票したissue\u003C/a>の本文がすごいです。\u003C/p>\u003Cblockquote>\u003Cp>The way we add iptables forwarding rules on Linux was an old hack that works for approximately nobody, except by coincidence. Refers specifically to an interface named ‘eth0’ which is very uncommon. Get re-added (but never deleted) every time the app starts. Creates a wildcard MASQUERADE rule rather than tight settings.\u003C/p>\u003C/blockquote>\u003Cp>これが、tailscale製品のLinuxサポートの土台です。中の人がそう書いています。\u003C/p>\u003Cp>それから、tailscaledは起動時にINPUT/FORWARDの先頭に\u003Ccode>ts-input\u003C/code>, \u003Ccode>ts-forward\u003C/code>を挿入し、POSTROUTINGのNATにts-postroutingを挿入します。これも普通の挙動っぽいですが、周期的に再配置して常に先頭にいるよう書き換えてくるのが本当にお行儀が悪い。iptables-restoreでルール順を組んでも、firewalldで管理しても、ufwで管理しても、tailscaleが定期的に後ろから殴って先頭に出てきてしまいます。\u003Ccode>--netfilter-mode=nodivert\u003C/code>を明示しようと周期再配置は止まりません。ユーザはホストのFWを管理してる気になっていますが、実際の支配権はtailscaleにあります。\u003C/p>\u003Cp>おまけにfwmarkも雑で、Calicoの上位16bitの使用とtailscaleの\u003Ccode>0x40000\u003C/code>, \u003Ccode>0x80000\u003C/code>が衝突し、CalicoのeBPFモードで\u003Ccode>Drop malformed IP packets Final result=DENY\u003C/code>が大量発生します。\u003C/p>\u003Ch1 id=\"hce2d3190fc\">MTU 1280ハードコード\u003C/h1>\u003Cp>\u003Ccode>tailscale0\u003C/code>のMTUは1280でハードコードされています。GCPの1460も、AWSの1500も、ジャンボフレームの9000も、全部関係なく1280です。\u003C/p>\u003Cp>WireGuardはICMP Frag Neededを信用しない実装で (DoS耐性のためらしい？)、まともなPath MTU Discoveryがありません。\u003Ccode>tailscale0\u003C/code>をさらにVXLANやWireGuardで重ねると実効MTUがさらに下がります。CiliumのVXLAN MTU 1280 + WireGuard 60 + tailscale 60で実効MTU ~1140になり、1252 byteのTLS Server Helloがちょうど切れる位置に来てdropされます。原因がMTUだと気付くのに半日。おかげさまで貴重な時間を無駄に過ごすことができました。\u003C/p>\u003Cp>私が知る限り、MTUを変える手段は存在しません。VPN製品でユーザにTCP MSS clampを要求するの、設計としてどうかと思います。\u003C/p>\u003Ch1 id=\"he6e50cfa9d\">ACLでDenyを明示できない\u003C/h1>\u003Cp>tailscaleのACLはdefault-denyを謳いながら、actionはacceptしか書けず、個別ルールでdenyを表現できません。\u003C/p>\u003Cp>一般的なFWアプライアンスであれば、\u003C/p>\u003Cpre>\u003Ccode>allow ssh from trusted_hosts\ndeny ssh\nallow * from any\u003C/code>\u003C/pre>\u003Cp>で「SSHは信頼できるホストからだけ、それ以外のSSHは拒否、他のポートは全公開」のようなものが書けます。しかし、tailscale ACLではこれが書けません。これは、最後の\u003Ccode>allow *\u003C/code>がSSH制限を上書きしてしまうためです。\u003C/p>\u003Cp>「\u003Ccode>tag:A\u003C/code>を\u003Ccode>tag:B\u003C/code>以外からブロック」を表現したいだけで、新タグを追加するたびにsrcリストを全書き換えする必要がでてきてしまい、かなりつらい。\u003C/p>\u003Cp>しかも\u003Ccode>acls\u003C/code>フィールドを省略するとdefault allow all。default-denyを謳いながら、書き忘れたtailnetは実質全許可。default-denyって言葉はどこに行ったんでしょうか？\u003C/p>\u003Cp>capability-basedの設計判断として一貫してるのは分かります。ただ、ACLを名乗りながら一般的なFWの動作と全く別物を提供するのはいただけない。\u003C/p>\u003Ch1 id=\"h507404909b\">SSOを強制\u003C/h1>\u003Cp>これは半分私の好みの話ですが。Tailscaleはアカウント作成にSSOを強制してきます。メールアドレスとパスワードでサインアップという選択肢がそもそも存在していません。\u003C/p>\u003Cp>個人で気軽に使う分にはGoogleで入れば済むので困らない〜みたいな主張をされがちですが、一段冷静に考えるとこれは結構な縛りです。tailnet全体のidentityが特定のIdPに紐付くということは、そのIdPアカウントが消えてしまった瞬間にtailnetから締め出されるということに他なりません。Googleアカウントが何らかの理由でロックされたら自分のホストにすら入られなくなる、というのを許容するかどうかは、判断する余地があって然るべきでしょう。しかしながら、Tailscaleはその判断を許してくれません。\u003C/p>\u003Cp>業務で使う場合だと話はさらに面倒で、「組織のGoogle Workspaceでサインアップしたが、退職時に組織のtailnetから個人を切り離したい」のような普通のユースケースがSSO強制のせいで歪な手順になります。tailnetを組織と個人で分けようとすると、別IdPを用意する必要があり、IdPの運用負担がVPNの運用負担に乗ってきます。VPNにIdPの寿命を縛られる、という構造そのものがおかしい。\u003C/p>\u003Cp>｢OIDC対応があるからセルフホストIdPでもいい｣という反論はあると思います。あると思いますが、家庭でVPNを立てたいだけの人間にKeycloakを建てさせるのは、もはやVPNの話ではありません。「VPNを使うために先にIdPを建てろ」という主張はそもそもの順序が逆です。\u003C/p>\u003Cp>そもそもTailscaleのcontrol planeはクローズドソースで、coordination serverに何を握られているかをユーザは検証できません。その上でidentityまでIdP経由で渡すことを強制される。便利の対価として、tailnetの存在条件をTailscale社とIdP事業者の二者に握られるのが現状です。Headscaleを建てれば回避できるのはそう。そうなんですが、それはTailscaleをやめることとほぼ同義でしょう。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>それでも私はたぶん明日もTailscaleを使います。文句を言いながら使うのが一番楽だからです。wg-quickの設定ファイルを書いて鍵を配って繋がらないと喚いている時間より、Tailscaleに苦しめられる時間の方がまだ短いというだけの話であって、これをTailscaleが優れていると言っていいのかはまた別のお話です。\u003C/p>\u003Cp>Tailscaleが約束する“It just works”は、自分のネットワークのどこにTailscaleが侵入しているかを完全に把握した人にだけ成立する魔法のようなものです。そもそも完全に把握している人は態々Tailscaleを使う理由はないでしょう。Tailscaleの“It just works”は、Tailscaleを使う理由がなくなった人にだけ動作する素晴らしい設計です。よくできていますね。\u003C/p>\u003Cp>いい感じの代替を知っている方がいれば是非教えてほしいです。それでは。\u003C/p>\u003Cp>\u003C/p>",[88,89],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":90,"createdAt":91,"updatedAt":92,"publishedAt":91,"revisedAt":92,"slug":93,"name":94},"lte0t59xm8sb","2025-05-02T16:12:38.708Z","2025-12-03T16:06:52.753Z","network","ネットワーク",{"id":96,"createdAt":97,"updatedAt":98,"publishedAt":98,"revisedAt":98,"title":99,"content":100,"tags":101,"is_no_index":46},"0bs4f42te","2026-04-29T00:46:22.148Z","2026-04-29T01:01:31.544Z","向き不向きの前に、まず手を動かすべきでは","\u003Ch1 id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>最近、というかここ数年、タイムラインを眺めていると、「プログラミングを始めました！」「LLMでアプリを作りました！」みたいな投稿を頻繁に目にするようになりました。それ自体はとても素晴らしいことだと思いますし、新しく何かを始めようとしている人を否定するつもりは毛頭ありません。\u003C/p>\u003Cp>ただ、その後の様子を眺めていると、どうにも釈然としない気持ちになることが多くあります。\u003C/p>\u003Cp>｢動きません｣｢エラーが出ます｣｢分かりません｣｢何もしていないのに壊れました｣、そういう質問が、携わるコミュニティで、Twitterで、Discordで、GitHubで、毎日のように流れてきます。それ自体は別に良いんです。分からないことを聞くのも大事だと思いますから。\u003C/p>\u003Cp>問題は、その質問に何の情報も含まれていないことや、そもそも自分の手元で起きていることを、自分で確認すらしていないことです。\u003C/p>\u003Cp>タイトルは少しばかり煽情的かもしれませんが、自分自身も普段から書いては消し、書いては消しを繰り返している身として、向いてないと切り捨ててしまう前に、もう少し言いたいことがあります。そんな話を、つらつらと書き散らしていきたいなと。\u003C/p>\u003Ch1 id=\"h833a42ac8c\">エラーメッセージは目の前にあるのに何故か読まれません\u003C/h1>\u003Cp>世の中には、エラーが出た瞬間に、画面の前で固まる方々が一定数いらっしゃいます。\u003C/p>\u003Cp>固まったまま、5分、10分、酷い時には30分。エラーメッセージは画面の真ん中にずっと表示されているのですが、その本文を読んでいる気配がありません。読んでいたら、その後の口から｢分かりません｣という言葉が出てくるはずがないからです。\u003C/p>\u003Cp>\u003Ccode>Connection refused\u003C/code>, \u003Ccode>Permission denied\u003C/code>, \u003Ccode>Module not found\u003C/code>, \u003Ccode>Unexpected token\u003C/code>.\u003C/p>\u003Cp>これらは何かの呪文ではなく、コンピュータがあなたに対して、丁寧に問題を教えてくれている、とてもありがたい案内です。\u003C/p>\u003Cp>エラーメッセージを読むというのは、別に難しいことではありません。\u003Ccode>Connection refused 127.0.0.1:5432\u003C/code>と書いてあれば、｢ローカルの5432ポート宛接続が拒否された｣と読めばいいだけですし、5432はPostgreSQLのデフォルトポートなのでPostgreSQLが起動していないか、接続情報が間違っているか、そのあたりに当たりがつきます。1分もあれば原因に辿り着ける問題に何を無駄な時間を費やしているのでしょう。\u003C/p>\u003Cp>｢英語が読めません｣と言う方もいらっしゃいますが、refusedをリフューズドと読み下せれば、それだけで意味は推測できますし、どうしても分からなければ翻訳ツールに通せば済む話です。あなたが普段スマホで観ている海外動画の字幕、あれだって機械翻訳でしょ？同じです。\u003C/p>\u003Cp>そして、信じられないかもしれませんが、エラーメッセージを読まない方々は、何度プログラムを実行しても出続けるエラーに対して、毎回最初から驚きます。｢またこのエラーが出ました｣と言うのですが、そら当然です。書かれていることに対処しない限り、何回実行しても同じエラーが出ますし、コンピュータは、あなたの祈りとかには反応しません。中身を変えず同一の試行を繰り返した挙句、違う結果を望むのはとても愚かなことです。小学生ですらやりません。\u003C/p>\u003Cp>エラーが出るというのは本来ありがたいことです。間違っている箇所を教えてくれるものですから、感謝しながら読んで直せば済みます。逆に、エラーも出ずに動いている、けれど結果が間違っている、という状況の方が、遥かに恐ろしく感じます。エラーは敵ではありません。\u003C/p>\u003Ch1 id=\"h7437e44bed\">｢こう動くはずなんですけど｣って、それあなたの感想ですよね\u003C/h1>\u003Cp>これも本当によく見かけるパターンで、本当に心底嫌いです。\u003C/p>\u003Cp>曰く、｢私は、ここはこういう風に動くはずだと思ってるんですけど、動かないんですよ｣と。\u003C/p>\u003Cp>そうですか。それで、あなたの思いと実際の挙動、どちらが正しいと思います？\u003C/p>\u003Cp>答えは決まっています。動いていないのなら、あなたの思い10割10分が間違っています。\u003C/p>\u003Cp>ところが、自分の認識を譲らない方が一定数居るようで、｢いや、ここはこう動くはずなんですよ｣だとか、｢でも、こういう風に書いたんですから、こう動くはずじゃないですか｣みたいなことを平然と言ってのけます。すごい自信家ですよね、感心します。でも実は違うんです。あなたの認識と、コンピュータの実際の挙動がズレているんです。修正すべきは、コードでも、ライブラリでも、コンピュータでもなく、傲慢なあなた自身です。\u003C/p>\u003Cp>思いをベースに議論しても、コンピュータは1nmも動きませんし、コードを動かすのは、そこに書かれている事実です。実際に出力された値、実際に走った行、実際に発生したエラー等々。それらだけが、原因の手がかりであって、あなたの思いは、原因の特定に役立ちません。\u003C/p>\u003Cp>デバッグの第一歩は、自分の思いを一旦脇に置くことでしょう。｢ここは絶対こう動いているはず｣と思い込んでいる箇所こそ、\u003Ccode>print\u003C/code>でも \u003Ccode>console.log\u003C/code> でも \u003Ccode>dbg!\u003C/code> でも構わないので、実際の値を出力して確認してください。殆どの場合、あなたの思い込みは外れています。本当に、本当に、外れています。騙されたと思って私を信じてください。\u003C/p>\u003Cp>ちなみに、デバッガを使ったことがない方も、かなりいらっしゃるようですが、VSCodeでもJetBrains系IDEでもブラウザのDevToolsでも、ブレークポイントを置いて、ステップ実行して、変数の中身を覗く、ということができます。これを覚えるだけで、デバッグの効率が劇的に変わります。printデバッグも悪くはないのですが、ちゃんとしたデバッガを使えるようになっておくと、人生の何割かを取り戻せます。暇なら覚えておきましょう。\u003C/p>\u003Ch1 id=\"h471a83884c\">詰まったら考えるより先に手を動かそう\u003C/h1>\u003Cp>エラーメッセージを読みました。それでも原因が分かりません。さて、どうしましょうか。\u003C/p>\u003Cp>ここで多くの方が、画面の前で固まる作業に戻ります。けれど、それでは何も解決しません。考え込んでも、コンピュータは何も教えてくれません。\u003C/p>\u003Cp>ここでやるべき事は、手を動かして状況を観測することだけです。\u003C/p>\u003Cp>怪しい箇所の値を出力する。リクエストの中身をダンプする。条件分岐の手前で実行を止めて状態を覗く。データベースに直接クエリを投げて状態を確認する、みたいな。動いているはずと思っている部分が、本当に思った通りに動いているか、ひとつずつ確かめていく必要があります。\u003C/p>\u003Cp>考えてから手を動かすのではなく、手を動かしながら考えるべきです。これは、おそらくベテランの方も全く同じことをしています。違うのは、観測の精度と、観測する場所を絞り込む速度だけです。経験を積むと最初に怪しいと睨んだ箇所が当たる確率が上がっていく、というだけの話に思えます。\u003C/p>\u003Cp>さて、｢闇雲に試して動いたら、それで本当に直ったと言えるんですか？｣と言われてそうです。良い指摘です。実際、闇雲に値を変えていたら偶然動いた、というケースは、本質的な問題の解決には至っていません。\u003C/p>\u003Cp>しかし、観測した結果から｢ここの値が想定と違っていた｣と特定できれば、それは立派な原因究明です。観測のないトライアンドエラーは博打ですが、観測のあるそれは、ちゃんとしたデバッグであると考えます。\u003C/p>\u003Cp>考え込まないでください。手を動かしてください。コードは、あなたの思考の中ではなく、メモリ上で実際に動いています。\u003C/p>\u003Ch1 id=\"hd508ed06f1\">｢LLMに聞いたら違うことを言ってきました｣\u003C/h1>\u003Cp>そんな事を言うくらいなら、はなから私なんかに聞かずとも、永遠に一人寂しくLLMとイチャついてればいいと思うのですが、最近特に増えたのがこのパターンです。\u003C/p>\u003Cp>｢LLMに聞いたらこう書けばいいって言われたんですけど、動きません｣\u003C/p>\u003Cp>そうですか？うんうん、すごいでちゅね〜！それで、あなたはその実装を読みました？\u003C/p>\u003Cp>LLMが生成したコードは、それなりに動きます。それなりに、です。ライブラリに破壊的な変更があれば動きませんし、APIが廃止されていれば動きません。そもそもLLMの学習データに無かったマイナーなライブラリだと、平気でハルシネーションを起こします。実在しない関数を、自信満々で提案してきたりしやがります。\u003C/p>\u003Cp>それを確認もせずにコピペして、挙句、動かなかったら｢LLMが嘘をついたんだ！！！！｣と憤慨するのは、些か筋違いです。\u003C/p>\u003Cp>LLMは便利なツールですが、便利なツールを使うために必要な能力は、依然として人間側に求められています。これは、Stack Overflowが流行った時も、Qiitaが流行った時も、ChatGPTが流行った時も、Clineに全部賭けたときも、Cursorが流行った時も、ずっと変わっていません。\u003C/p>\u003Cp>LLMに頼ること自体は何も悪くありませんし、私も普段からLLMにかなりの出力を強要しています。GeminiにもOpusにも毎日お世話になっています。ただ、LLMが出力したコードは必ず自分で読みます。読んで理解して、おかしいところがあれば修正します。これをやらない人間がLLMを使うと、ただのコピペ職人が爆誕します。それも、自分が何をコピペしているかすら分からない、史上最悪のコピペ職人が、です。\u003C/p>\u003Cp>ちなみに、LLMに｢このコード動きません｣と投げて、LLMが返してきた修正をまたコピペして、それでも動かない、というループに陥っている方も散見されます。それを何時間も繰り返した挙句、LLMはダメだと結論付ける。残念ながら、ダメなのはLLMではなく、あなたの頭です。\u003C/p>\u003Cp>LLMはあなたの状況を完全には理解していません。あなたが理解した上で、適切に指示を出す必要があります。\u003C/p>\u003Cp>以前にも別の記事で書きましたが、LLMがどれだけ優秀になっても、使う人間の側に何もなければ、出力される成果物も同様のものでしょう。\u003C/p>\u003Ch1 id=\"h5cbe4915a0\">自分が何をしたいのか整理して\u003C/h1>\u003Cp>これも本当によく言われます。やめてくださいね。\u003C/p>\u003Cp>｢○○がしたいんですけど、どうすればいいです？｣\u003C/p>\u003Cp>その○○が、抽象的すぎてなんと言うかものすごくふわっとしている。\u003C/p>\u003Cp>｢Webサイトを作りたいんだけど〜｣\u003C/p>\u003Cp>どんなサイトを？静的サイトですか、動的サイトですか？認証認可は必要ですか？どんなコンテンツを載せたいですか？利用者は何人を想定していますか？運用のコスト感は？収益化したいですか、趣味ですか？\u003C/p>\u003Cp>これらはあなたが答えるべき質問です。わたしが答える質問ではありません。\u003C/p>\u003Cp>目的を整理するというのは、プログラミング以前のごく当たり前の力です。これができないと、コードを書く以前に何を書けばいいかすら定まりません。そんなんじゃお話になりません。\u003C/p>\u003Cp>これはエンジニアリングの問題ではなく、もっと手前の自分の思考を整理して言語化する国語の問題です。プログラミングは思考を厳密にコンピュータが理解できる形に翻訳する作業ですから、思考が曖昧なままではそもそもコードに翻訳することすらままならないことは自明でしょう。\u003C/p>\u003Cp>紙でもメモアプリでもLLMでも構わないので、自分が何をしたいのかを書き出してください。やりたいこと、やるべきではないこと、分かっていること、分からないこと等々。整理されていない要望をぶつけられても、こちらは何も答えようがありません。\u003C/p>\u003Cp>そして、もうひとつ。課題文を読めない方も本当に多いです。\u003C/p>\u003Cp>例えば、業務でちょっとしたツールを依頼する場面で、｢Slackに来たメッセージのうち、特定のキーワードを含むものをスプレッドシートに記録してください｣と伝えたとします。これは、｢Slackからメッセージを受け取る｣、｢キーワードでフィルタする｣、｢スプレッドシートに書き込む｣の3つの工程に分解できる、ごく単純な依頼です。\u003C/p>\u003Cp>ところが、これが分解できない方は、依頼文を一塊のまま受け取って、最初の一歩で固まります。｢何から手を付ければいいんでしょうか｣と相談されるのですが、お渡しした依頼文には既に手を付ける順番が書いてあります。一文として読むのではなく、要素ごとに区切って読んでください。それだけで、何をすればいいかが見えてきます。\u003C/p>\u003Cp>これはもうプログラミング以前の、国語の問題です。\u003C/p>\u003Cp>それすらできないようであれば、小学生のお勉強からやり直すことを推奨します。\u003C/p>\u003Ch1 id=\"h0d45d9ccaf\">大きい問題に挑む前に小さい問題に分割して\u003C/h1>\u003Cp>｢○○を作ってください｣という課題が出たとします。例えば、社内向けのちょっとしたダッシュボード、みたいな。\u003C/p>\u003Cp>ここで、できない方は、いきなり全部を一気に書こうとして、数時間悩んだ末に画面の前で固まる作業に戻ります。｢データ取得をどう書いて、それをどう加工して、どうUIに表示して、認証はどうして、エラー処理はどうして…｣と、全てを同時に考えようとして、結局1行も書けないのです。\u003C/p>\u003Cp>わたしならこう書きます。\u003C/p>\u003Cpre>\u003Ccode class=\"language-csharp\">var dashboard = new Dashboard();\nvar data = await dashboard.FetchDataAsync();\ndashboard.Render(data);\u003C/code>\u003C/pre>\u003Cp>3行、終わり。これでダッシュボードが完成です。\u003C/p>\u003Cp>「いや、それじゃ動かないじゃないですか」と思われるかもしれません。その通りです。動きません。\u003Ccode>Dashboard\u003C/code> とかいう存在しない抽象クラスを呼んでいるだけですから、当然です。\u003C/p>\u003Cp>しかし、これで全体像は捉えられます。あとは適当に\u003Ccode>Dashboard\u003C/code>クラスと\u003Ccode>FetchDataAsync\u003C/code>、\u003Ccode>Render\u003C/code>の中身をそれぞれ実装するだけです。中身もいきなり全部書こうとせず、同じように存在しないメソッドを呼んでおいて後から埋めていきます。これを繰り返していると、いつの間にか動くものが出来上がっています。\u003C/p>\u003Cp>トップダウン設計と呼ばれたりもしますが、名前はどうでも良くて、要するに、自分が一度に考えられる範囲まで、問題を細かく細分化するということです。難しい問題を、難しいまま扱える人は、ごく一部の天才だけであって、私を含む殆どの無能は、問題を1ファイルに1,000行で書ける程度の小ささに分解しないと解けません。\u003C/p>\u003Cp>勉強すれば難しい問題も解けるようになると期待されている方もいらっしゃるかもしれません。残念ながら、勉強しても難しい問題は難しいままです。賢くなるのではなく、問題を小さくする技術を身につけるべきです。これは才能の話ではなく、訓練の話なので、正直誰でも身につけられるかと思います。\u003C/p>\u003Ch1 id=\"h89f6c9455f\">動的型付け言語を選ぶ前に考え直しませんか\u003C/h1>\u003Cp>ここから少し、私の個人的な好みが強く入ります。先に断っておきますね。\u003C/p>\u003Cp>私は動的型付け言語が好きではありません。PythonもRubyもJavaScriptも、業務では書きますが、好きでは無いです。理由は単純で、書いた本人が何を扱っているか分からないコードを、後から他人が読んで分かるはずがないためです。\u003C/p>\u003Cp>巷では｢Pythonは書きやすい｣だとか、｢JavaScriptは手軽｣だとか、馬鹿の一つ覚えのように永遠と言われています。確かに、書き始めるまでのハードルは低いでしょう。しかし、それは短期的には楽であるというだけの話であって、長期的には負債が積み上がります。\u003C/p>\u003Cp>引数が何の型か書かれていない関数。返り値が状況によって変わる関数。エラー時に\u003Ccode>None\u003C/code>を返したり、空文字を返したり、例外を投げたりする一貫性のない設計。これらは、動的型付け言語特有の問題ではありませんが、動的型付けの環境では検出されないまま放置されやすい傾向にあります。\u003C/p>\u003Cp>そして、そういうコードを書いた本人は、3ヶ月後には何ひとつ覚えていません。私にも前科があります。\u003C/p>\u003Cp>これからプログラミングを始めるのであれば、最初から型のある言語を選んでください。GoでもRustでもKotlinでもSwiftでも、TypeScriptでも、好きなものを選べば良いと思います。動的型付け言語から始めて後から型に移行するのは苦痛でしょうし、最初から型に親しんだ方が遥かに早いかと思われます。\u003C/p>\u003Ch1 id=\"ha2c9bbc147\">質問とか\u003C/h1>\u003Cp>環境, やりたかったこと, やったこと, 起こったこと, 試したこと, 仮説\u003C/p>\u003Cp>このあたりが揃っていれば、回答する側は、スムーズに原因を絞り込めます。逆に、これらが何ひとつ揃っていない｢動きません｣だけの質問は、回答できません。回答できないというより、回答する前の調査だけで時間が溶けて、結果として誰も答えなくなります。私ならムカついて返信しないこともあるかもしれません。\u003C/p>\u003Cp>｢初心者なので〜｣と前置きする方もいますが、初心者かどうかは関係ありません。初心者であろうと、そうでなかろうと、項目を埋めることはできますし、初心者ほど丁寧に書くべきです。慣れている人なら省略して伝わることも、初心者の場合は丁寧に書かないと伝わらないためです。\u003C/p>\u003Cp>これは偏見ですが、質問が下手な人は概ね実装も汚いです。例外もあるかもしれませんが、私の観測範囲ではほぼ相関しているように思います。質問を組み立てることは、(本質的に)状況を分解して構造化する作業であって、それがプログラミングそのものであるためです。質問が組み立てられないということは、思考が組み立てられないということと同義で、思考が組み立てられなければ、コードも書けないでしょう。当たり前です。\u003C/p>\u003Ch1 id=\"h4eac98b528\">｢ググれ｣が冷たく聞こえるのなら申し訳ないですが\u003C/h1>\u003Cp>わたしは以前、ggrksは最大限の優しさだと書きました。今でも、その考えは変わっていません。\u003C/p>\u003Cp>技術的な質問に対して｢自分で調べてね〜｣と返すのは、決して冷たいわけではありません。\u003C/p>\u003Cp>\u003Cs>答えるのが面倒くさい訳ではないと言ってしまうと大嘘になるのですが、\u003C/s>｢あなたが自分で調べれば、もっと正確で網羅的な情報が手に入りますよ｣の意であり、｢私が中途半端に答えるよりも、検索した方が確実ですよ｣という意味であり、｢自身で調べる力を身につけた方がしあわせになれますよ｣という意味でもあります。\u003C/p>\u003Cp>これを冷たいと感じる方は、自分が答えを与えてもらえる立場だと思い上がっているのかもしれません。しかし、私はあなたの家庭教師でも、メンターでも、サポート窓口でもありません。私は\u003Cs>とても優しいので\u003C/s>聞かれたことにはなるべく答えるようにしていますが、それを当然のように要求されるのは、少し違うように思います。\u003C/p>\u003Cp>ついでに、ググる行為自体にも割とスキルが要ります。｢ECONNREFUSEDが出たんだけどどう治すの？｣みたいな雑な自然言語ではなく、｢ECONNREFUSED 127.0.0.1:5432 postgres:18.3-alpine｣のように、自分の環境に近い具体的なワードを並べる、英語で検索する、公式ドキュメントを優先する、GitHubのIssuesを見る、Stack Overflowは新しい順に並び替える等の工夫が必要です。\u003C/p>\u003Cp>ググれと言われて冷たいと感じる前に、自分のググり方を一度見直してみてください。それだけで、解決できる問題の幅が多分広がるかと思います。\u003C/p>\u003Ch1 id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp>正直、プログラミングなんてものはどんなに馬鹿でも時間さえかければできるようになります。\u003C/p>\u003Cp>これは、別に綺麗事として書いているのではなく、私自身がソースです。\u003C/p>\u003Cp>私自身、地頭(笑)が良いタイプでも、論理的思考力が突出しているタイプでもなく、どちらかと言えば落ちこぼれ側の人間です。覚えたての頃に書いていたコードなんて、今読み返すと冗談みたいに酷いものですし、未だに簡単なバグで数時間詰まることもざらにあります。\u003C/p>\u003Cp>何とかコードを書いて、何とかお仕事を頂いて、何とか運用できているのは、ただただ触っている時間がそれなりに長いからというだけでしょう。そこに特筆すべき才能なんてものは介在していません。\u003C/p>\u003Cp>なので、自分には才能がないからという理由で諦めるのは、的外れだと思っています。あなたに無いのは才能ではなく、ただの時間です。退屈な作業を、何百時間、何千時間と積み上げる時間が足りていないだけです。\u003C/p>\u003Cp>逆に言えば、これを積み上げる気が無いのなら、永遠にできるようにはなりませんし、｢いつか分かるようになる｣だとか、｢コツを掴めば｣みたいな、そういう商材屋の好きそうな胡散臭い魔法はありません。触った分だけ出来るようになります。\u003C/p>\u003Cp>それが面倒だと感じるのであれば、それは多分、あなたがプログラミングをそこまで好きではない、というだけの話です。それは別に悪いことではありません。世の中には、プログラミング以外にも、楽しい営みがいくらでもあります。プログラミングが特別な何かだと思い込むのは、業界の人間の自意識過剰でしょう。\u003C/p>\u003Cp>何とかしたいと思うのであれば、何とかしてください。何とかしたくないのであれば、別に何ともしなくて構いません。\u003C/p>\u003Cp>埋蔵金探しとかも新鮮で楽しそうですよ。\u003C/p>\u003Cp>それでは。\u003C/p>",[102,103,109],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":104,"createdAt":105,"updatedAt":106,"publishedAt":105,"revisedAt":106,"slug":107,"name":108},"8q8qyy8sz3","2025-04-29T08:44:23.406Z","2025-12-03T16:07:24.495Z","programming","プログラミング",{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":111,"createdAt":112,"updatedAt":113,"publishedAt":113,"revisedAt":113,"title":114,"content":115,"tags":116,"is_no_index":46},"7xdhut0q03","2026-04-20T22:00:47.483Z","2026-04-20T22:06:59.556Z","個を個として","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">どうにも眠れない夜です。仕方がないので、思ったことを色々ぼんやりと書いています。\u003C/p>\u003Cp style=\"text-align: start\">先日、誰かと話していて「属性で括るのは良くないよね」と言いかけました。ギリ踏みとどまって、言いませんでした。世の中的には、たぶん正しい主張なのだと思います。\u003C/p>\u003Cp style=\"text-align: start\">私は、偉そうな初学者が嫌いです。傲慢な人間が嫌いです。知性の乏しい人間が嫌いです。これらをこうして並べている時点で、私はそれらを思いっきり広義の属性で括っています。括ることをやめて、これらの嫌悪を表現する方法を、生憎と私は持ち合わせていません。\u003C/p>\u003Ch1 id=\"hc544c60f77\">カテゴライズから逃れられないらしい\u003C/h1>\u003Cp style=\"text-align: start\">聞くに、人間の認知というのは、結局のところカテゴライズを通してしか働かないようにできているらしいです。目の前にいる存在を猫として認識した瞬間、過去に出会ったすべての猫と、これから出会うかもしれない猫たちと、目の前のその子を、同じラベルの中に押し込めている。猫の名を持つラベルなしに、その存在を指し示そうとすると、認知が成り立たなくなってしまう。\u003C/p>\u003Cp style=\"text-align: start\">であるから、｢属性で括るな」という主張を文字通りに受け取ろうとすると、それは概ね「認知をやめろ」と言っているのと大差無いものだと思います。\u003C/p>\u003Ch1 id=\"h7cbc3267a6\">現象に名前を付けているだけ\u003C/h1>\u003Cp style=\"text-align: start\">こうやって自分を正当化してきました。｢ここで言う偉そうな初学者というのは、特定の属性として括っているのではなく、あくまでも私が観測した振る舞い、それそのものに名前を付けているだけなのだ｣、という感じで。\u003C/p>\u003Cp style=\"text-align: start\">ですが、よくよく考えるとこれは都合のいい言い訳でしかなくて\u003C/p>\u003Cp style=\"text-align: start\">名前を付けてしまったが最期、それはある種のラベルになってしまいます。過去の事例を束ね、未来の事例を予期するための枠組みになってしまいます。概ね構造的には属性で括るのと、全く同じ状態です。\u003C/p>\u003Cp style=\"text-align: start\">極論を言ってしまうと、固有名詞ですら例外ではなく、私が私を呼んだ瞬間、私は私を私として、ひとつの同一性に押し込めているという解釈もできます。昨日の私と今日の私は厳密には違う状態にある(べき)はずなのに、ラベルがそれを連続体として扱わせているためです。指示語ですら、世界を指されたものと指されていないものに分けてしまう。\u003C/p>\u003Cp style=\"text-align: start\">言語そのものが世界を切り分けるための都合のいい道具であると私は考えます。だとすれば、属性で括らない表現というものは、原理的に存在し得ないのではないでしょうか。\u003C/p>\u003Ch1 id=\"h057b7d27d3\">多様性\u003C/h1>\u003Cp style=\"text-align: start\">すごく嫌な世の中になったもので、最近、多様性だとか個性の尊重だとか配慮だとか、そういう言葉が粗雑にあちこちで掲げられているのを目にします。\u003Cs>私には到底理解できませんが、\u003C/s>それ自体は多分良いことなのでしょう。\u003C/p>\u003Cp style=\"text-align: start\">しかしながら、その素晴らしい看板の下で実際に行われていることといえば、結局のところ、望ましいラベルと望ましくないラベルを選り分けて、後者を排しているだけのようにしか思えません。\u003C/p>\u003Cp style=\"text-align: start\">勿論、｢ここで言う多様性が本来は社会制度上の包摂を扱う概念であって、個別の人間関係における受容を保証するものではない｣という趣旨の反論はあり得るのだと思います。それはそうなのでしょう。ただ、私が主張したいのは、その制度的な議論が個人の振る舞いのレイヤにまで降りてきたとき、結局は雑なラベル運用に堕しているという点です。\u003C/p>\u003Cp style=\"text-align: start\">個を個として受け止めるということが本当にできるのであれば、わざわざ多様性なんていう大仰な概念を持ち出す必要すらなく、一人ひとりに対してその人として向き合えばいいだけの単純な話のはずです。\u003C/p>\u003Cp style=\"text-align: start\">しかし、現実としては殆どそうはなっていません。属性で雑に括った上で「あなたのその属性を尊重します」と不誠実な嘘をついているだけ。そんなものは、個の受容ではなく属性の追認に過ぎないのではないでしょうか。個を個として受け止められない多様性は、その時点でもう破綻しています。破綻したものを、そのまま尊重する必要があるとは、到底私には思えません。\u003C/p>\u003Cp style=\"text-align: start\">私は目の前に立つ人間をひとりのあなたとして受け止めたいと思っています。しかし、それは決して簡単なことではなくて、私自身できているとは到底言えません。その辺の何も考えてないようなヘンテコ人間が偉そうに多様性について講釈を垂れる立場にあるのかというと、本当に疑わしく思います。\u003C/p>\u003Ch1 id=\"h0fb99d999f\">無関心\u003C/h1>\u003Cp style=\"text-align: start\">だからこそ、私は他社に対して程よく無関心であろうと思うのです。\u003C/p>\u003Cp style=\"text-align: start\">無関心と書くと冷たい印象を与えるかもしれませんが、ここで言いたいのはそういうことではなくって。粗雑なラベルで他人を括って、そのラベルに基づいて勝手に評価したり、勝手に共感したり、勝手に憐れんだりしないこと。相手のことを本当に知りもしないのに、わかったような顔をしないこと。概ねそんな感じです。\u003C/p>\u003Cp style=\"text-align: start\">前述の話と矛盾しているように見えるかもしれません。｢ラベリングから逃れられないと言っておきながら、無関心であろうとするとはどういうことか｣と。\u003C/p>\u003Cp style=\"text-align: start\">私の中での認識は概ねこういう整理です。ラベル自体は認知の道具として使い続けるしかない。しかし、そのラベルを根拠に他人を裁定しに行かない、評価しに行かない、わかった気にならない。道具として使うことと、道具を振り回して他人をボコボコに殴ることは別の話だと思っています。\u003C/p>\u003Cp style=\"text-align: start\">紛うことなき自画自賛ですが、これはある種の優しさなのだと思います。少なくとも、雑なラベリングで他人を撫でくり回すよりは、余程マシな振る舞いなのではないかと。\u003C/p>\u003Ch1 id=\"h6799f15c52\">返戻\u003C/h1>\u003Cp style=\"text-align: start\">ここまで書いた上で、最初の自分の発言に立ち返ってみましょう。私は偉そうな初学者が嫌いだ、と書きました。これは、粗いラベリングだったのでしょうか。\u003C/p>\u003Cp style=\"text-align: start\">おそらく誰が読んでも粗く感じることでしょう。「偉そう」も「初学者」も、それ自体は個別の振る舞いの束を雑に括った概念に過ぎません。本当に嫌っているのは、自分の理解の浅さに気づかずに断定してしまう態度であったり、他人の指摘を受け入れる構造を持たない振る舞いであったり、上手く言い表せませんが、そういう、もっと具体的な何かだったはずです。\u003C/p>\u003Cp style=\"text-align: start\">それでも、私は今後もこのラベルを使い続けるのだと思います。解像度を上げきった表現は、冗長すぎて使い物にならないので。日常の言葉というのは、ある程度の粗さを引き受けることで、ようやく成立していると考えます。\u003C/p>\u003Cp style=\"text-align: start\">引き受けた上で、自分のラベルの粗さを省みたり顧みなかったりする。(誤字じゃないですよ)\u003C/p>\u003Cp style=\"text-align: start\">実行可能な範疇で誠実であろうと思います。\u003C/p>",[117],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":119,"createdAt":120,"updatedAt":121,"publishedAt":122,"revisedAt":121,"title":123,"content":124,"tags":125,"is_no_index":46},"l_gwwt21az06","2026-04-18T08:31:28.769Z","2026-04-18T18:16:15.075Z","2026-04-18T08:36:42.914Z","20歳になりました","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">早いもので、とうとう先日20歳になってしまいました。世間一般で言えば立派な大人の仲間入りということになるのでしょうが、自認はまだまだ5歳児です。時間の流れだけは残酷なほど速く過ぎ去っていくので、ただただ戸惑うばかりです。\u003C/p>\u003Cp style=\"text-align: start\">思い返せば高校時代、卒業した後の自分の姿なんて、微塵も想像していませんでした。当時の私にとって、あの閉鎖的で代わり映えのしない日々の生活が世界の全てでした。その先の未来なんてものは想像すらできず、ぱっと死んでしまうような、そんな刹那的な感覚すら抱いていたのかもしれません。\u003C/p>\u003Cp style=\"text-align: start\">しかし現実は無慈悲に続くもので、時を経て、高校を卒業してからあっという間に2年の月日が経過してしまいました。\u003C/p>\u003Cp style=\"text-align: start\">相変わらず1人怠惰な日々を送っています。20歳になったからといって、急に視界が開けたり、精神的に成熟したり、劇的に何かが変わるわけでもありません。明日からもきっと似たり寄ったりの、良くも悪くもつまんない日々を淡々と消化していくんだと思います。\u003C/p>\u003Cp style=\"text-align: start\">数年前の民法改正によって、成人年齢自体は18歳に引き下げられました。しかし、お酒やタバコ、各種賭博等の規制は依然として20歳がボーダーとして残されています。\u003C/p>\u003Cp style=\"text-align: start\">この年齢制限がようやく解除されたことで、社会的には、真の意味で大人(笑)になれたのかもしれません。とはいえ、中身の精神性は先述した通り未熟なままなので、あまり大きい声では言えないのですが。\u003C/p>\u003Cp style=\"text-align: start\">ただ年齢が一つ増えただけで、許可される行為の幅が広がるというのは、なんだか不思議なシステムだと改めて感じます。\u003C/p>\u003Cp style=\"text-align: start\">それはそれとして。せっかくなので初めての飲酒というやつを経験してみました。\u003C/p>\u003Cp style=\"text-align: start\">選んだのはほろよいの桃っぽい甘いやつ。\u003C/p>\u003Cp style=\"text-align: start\">「初飲酒とはいえ、アルコール3%だしいけるっしょ」と、完全に高を括っていたのですが、結果は無惨な敗北でした。\u003C/p>\u003Cp style=\"text-align: start\">全然顔が赤くなってしまい、東アジア人を感じました。アセトアルデヒドを全く分解できていなさそうな、どうしようもない間抜け顔です。\u003C/p>\u003Cp style=\"text-align: start\">自分の家系はそれなりにアルコールに強い方だと思い込んでいたので、この圧倒的な弱さにはちょっとびっくりしました。\u003C/p>\u003Cp style=\"text-align: start\">そういえば幼少期、両親が離婚する以前は「お前は近所の海で拾ってきたんだ（意訳）」的なことをよく言われていた記憶があります。アルコール適性の無さを鑑みるに、本当に近所の海から拾われてきた捨て子だったのかもしれません笑\u003C/p>\u003Cp style=\"text-align: start\">これは冗談です。あしからず。\u003C/p>\u003Cp style=\"text-align: start\">とまあ、そんなどうしようもない20歳の幕開けを迎えたわけですが、私の日常自体はこれまでと何一つ変わっていません。\u003C/p>\u003Cp style=\"text-align: start\">そういえば、すっかり書き忘れていましたが、またMisskeyインスタンスを建てました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey.blue/\">https://misskey.blue/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">過去に文句を散々書き連ねておきながら、本当に性懲りもない人間だなと自分でも呆れています。喉元過ぎれば熱さを忘れるとはよく言ったもので、ふと手持ち無沙汰になると、あの空気が恋しくなってしまうようです。\u003C/p>\u003Cp style=\"text-align: start\">今回はVultrを借りて、2 vCPUs / 4GiB のインスタンス上にDockerで構築しています。ただ、分かってはいたことですが、Misskeyを動かすにはこのスペックだとリソース的にかなりカツカツで厳しいのが現状です。要求されるリソースがそれなりに重いため、常にswapと睨めっこしながらの綱渡り状態を強いられています。\u003C/p>\u003Cp style=\"text-align: start\">快適さを求めるのであればさっさとスケールすべきなのですが、一つ上のプランに引き上げるとなると、支払い額ほぼ2倍に跳ね上がるようで、十数人程度しかいないインスタンスにその出費を受け入れるかと言われると、どうしても「うーん…」と躊躇してしまうのが正直なところです。\u003C/p>\u003Cp style=\"text-align: start\">現状は、仲のいい子を数人呼んでひっそりと運用しているだけです。ただ、やはり少人数だとどうしてもLTLの動きが鈍く、わちゃわちゃとした楽しさに欠けるというか、純粋にTLが寂しいんですよね。\u003C/p>\u003Cp style=\"text-align: start\">もっと人を呼んでLTLを賑やかにしたいという思いは山々ですが、公開鯖にしてしまうと、かつてと同じ轍をまた踏む未来が容易に想像できてしまいます。\u003C/p>\u003Cp style=\"text-align: start\">人、ほしい。\u003C/p>\u003Cp style=\"text-align: start\">それでは\u003C/p>",[126,127],{"id":41,"createdAt":42,"updatedAt":43,"publishedAt":42,"revisedAt":43,"slug":44,"name":45},{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":129,"createdAt":130,"updatedAt":131,"publishedAt":131,"revisedAt":131,"title":132,"content":133,"tags":134,"is_no_index":46},"jucf8gnoagb4","2026-04-09T04:58:35.980Z","2026-04-09T05:42:14.678Z","LLMがどれだけ優れていても、つまらない人間はつまらない","\u003Cp>最近、タイムラインを眺めていると、「LLMを使いこなせるのは一部の賢い連中だ」のような言説をよく目にします。\u003Cbr>確かにそれは一面の真実なのでしょうが、実際のところそこに存在しているのは、中身が空っぽな人間ほど、LLMによってガワから見た能力が派手に底上げされるという、極めて残酷なまでの非対称性でしょう。世間では知性の民主化などという美辞麗句が踊っていますが、その実態は単なる能力の底上げなどではなく、論理的な思考回路を持たない人間が高度な知性を出力として模倣し、あたかも自分の実力であるかのように偽装できてしまうという、歪な構造的欠陥に他ならないと感じています。\u003C/p>\u003Cp>たとえば、専門知識も論理的訓練も受けていない人間が、洗練された論理構築が可能な大規模言語モデルを使用したとしましょう。その間に生じる知的な解像度の乖離は、もはや対話すら成立しない絶望的な断絶です。認知の深さが一定以上異なれば、前提とする論理のレイヤすら共有できないのが常ですが、その圧倒的な差がある知性をあたかも自分の手足のように操っていると錯覚した人間がどうなるかは、想像に難くありません。本人は、LLMが吐き出したその高度で緻密な内容を、論理的に咀嚼し、真に理解することすら土台不可能なのです。内容を検証する力がない以上、彼らにとってLLMの出力は疑う余地のない神託へと昇格し、それを引き出した自分までもが全知全能の存在にでもなったかのような致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>一方で、もともと高度な専門性を備えた人間が高度なLLMを使ったところで、そこにある能力の差はそれほど大きくはありません。彼らにとってLLMは24/365で文句ひとつ言わずに稼働し、要求に対して及第点の成果を出す便利な手下程度の認識に収まるでしょう。彼らは出力される情報の裏にある限界や、統計的なもっともらしさの脆さを理解しており、自分の知性と照らし合わせながらその境界線を慎重に引くことができます。\u003C/p>\u003Cp>しかし、思考の基盤を持たない層にとって、LLMの出力は検証不可能な神託そのものとして機能してしまいます。LLMの出力を論理的に検証するだけの批判的思考力も、背景にある膨大な知識体系も欠如しているため、出力された内容を本質的に理解することも、その正当性を疑うことも、ましてや学術的な反証を試みることもできません。結果として彼らは、自分が突然、森羅万象を司る全知全能の存在にでも昇華されたかのような、致命的な自己肥大に陥るわけです。\u003C/p>\u003Cp>これは将棋のルールすら怪しい初心者が、将棋ソフトの最善手と言っている提案をただ無批判に盤上へ再現し、それでプロに勝利して「自分の才能がようやく世界に追いついた」と本気で悦に入っているような、極めて滑稽で厚顔無恥な喜劇です。こうしたLLMによって底上げされた無能たちは、いまやLLMとの対話で得た真理(のようなもの)という名のゴミを誇らしげに掲げ、あらゆる専門分野へ土足で自信満々に侵入を開始しています。彼らの発言は驚くほど定型化されており、正直見ていて反吐が出ます。\u003C/p>\u003Cp>彼らの常套句はこうです。\u003Cbr>「俺はついに、世界を根底から覆す画期的な新理論を発見してしまった。AIがこれは100%本物だと言っている」だとか、「これは複数の最高峰モデルをn時間以上も激論させ、n万円分ものコストを費やしてようやく抽出に成功した究極原理だ」といった具合です。さらに性質の悪いことに、「この理論は常識に縛られた凡人には理解しにくいかもしれないが、AIはこの独創的かつ鋭い着眼点を称賛していた」などと宣い、あたかも自分だけがLLMという高次元の知性と精神的に共鳴できる、選ばれし預言者であるかのように振る舞うのです。\u003C/p>\u003Cp>彼らは「天才たちが最後まで言語化できずにいた核心を、自分だけがついに最も明晰な形で取り出した」と語りますが、その中身を解剖すれば、そこにあるのはLLMが確率論に基づいて繋ぎ合わせた、耳当たりの良い単語のパッチワークに過ぎません。ここ数年、こうしたLLM製のプロパガンダを武器に各所のコミュニティを荒らし回る事例が散見されますが、それは知性に対するこの上ない侮辱であり、文明的な対話の破壊活動に等しいものです。自分の脳内で再構築もできず、論理的な因果関係を自らの言葉で説明すらできないのであれば、その借り物の羽で他者を威圧する行為がどれほど恥べきことか、いい加減自覚すべきでしょう。\u003C/p>\u003Cp>これからのLLM全盛時代において、真に求められる能力とは、出力結果を鵜呑みにして全知全能感に浸ることではありません。分からないことを、分からないままの状態として脳内に保留できる力、自分の理解が及ばない領域を正確に定義するメタ認知そのものです。LLMがどれほど尤もらしい真理を提示したところで、それが自分の論理として肉体化されていないのであれば、それは単なる無意味な記号の羅列に過ぎません。\u003C/p>\u003Cp>結局のところ、彼らは自分たちが楽をすることしか考えておらず、その思考停止のツケを未来の誰かが払わされることを全く想像できていない想像力の欠如こそが、彼らが無能であることの最大の証明ではないでしょうか。全知全能(笑)に酔いしれるのは勝手ですが、その酔いが覚めたときに鏡の前に立っているのが、言葉の重みすら計ることのできない空虚な自分自身であるという事実に、彼らはいつ直面することになるのでしょうか。\u003C/p>\u003Cp>まあ、温室の中で空虚ささえも心地よい万能感として消費し続けるのが、彼らにとってのしあわせなのかもしれませんね。非常に気持ちが悪いので消えてほしいですが。\u003C/p>\u003Cp>それでは。\u003C/p>",[135,136],{"id":13,"createdAt":14,"updatedAt":15,"publishedAt":14,"revisedAt":15,"slug":16,"name":17},{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":138,"createdAt":139,"updatedAt":140,"publishedAt":140,"revisedAt":140,"title":141,"content":142,"tags":143,"is_no_index":46},"5nf10o2ud0j","2026-04-02T14:00:30.562Z","2026-04-02T14:01:54.127Z","ねこちゃんをお迎えしました2","\u003Cp>どうも、わたしです。\u003C/p>\u003Cp>以前から検討していたのですが、新しく2匹目のねこをお迎えすることにしました。\u003Cbr>今回お迎えしたのは、ノルウェージャンフォレストキャットの女の子(7ヶ月齢)です。名前は「ラテ」にしました。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/3204c913ca2a4c6b81fcf3660d7af26b/beauty_1774772499640.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>一般的にはもっと幼い時期にお迎えするケースが多いかと思いますが、あえてこの月齢の子を選びました。\u003C/p>\u003Cp>7ヶ月ともなると、性格もかなり安定していますし、体調面での不安も少ないです。ノルウェージャンらしい立派な飾り毛や毛吹きも既に出始めており、成猫になった時の完成形がイメージしやすかったのも決め手の一つでした。\u003C/p>\u003Cp>先住猫のモカちゃんは、お迎え当日に添い寝をしてくれるほどの驚異的な適応能力を持っていました。対して今回のラテちゃんは、初日こそ少し慎重でしたが、数時間もすれば部屋の探索を始めるなど、なかなかの図太さを見せてくれています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bb54aa22e13446e79786cc85f973296d/beauty_1774942744631.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cp>先住猫のモカちゃんは、ご縁があって譲っていただいた子だったので、実質的な生体価格というものは発生していませんでした。\u003Cbr>しかし、今回は順当な？ルートでお迎えしたため、総額で25万円近くかかりました。\u003C/p>\u003Cp>猫を購入する経験がなかった身からすると、この金額には正直かなりの衝撃を受けています。支払う瞬間に一瞬手が止まるくらいの重みはありましたが、まあ、お迎えしてしまったものは仕方がありません。\u003Cbr>これからこの2匹が仲良く並んで寝てくれる日を目標に、しっかり面倒を見ていこうと思います。\u003C/p>\u003Cfigure style=\"text-align: left;\">\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/106405f266634defae61fc31b18da359/beauty_1774881852261.jpeg\" alt=\"\" width=\"1650\" height=\"928\">\u003C/figure>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/a5212af8ab2548c6b4fd0abe6d7476d0/beauty_1774869951812.jpeg\" alt=\"\" width=\"3840\" height=\"2160\">\u003C/figure>",[144,145],{"id":41,"createdAt":42,"updatedAt":43,"publishedAt":42,"revisedAt":43,"slug":44,"name":45},{"id":146,"createdAt":147,"updatedAt":148,"publishedAt":147,"revisedAt":148,"slug":149,"name":150},"zvbbh2k4o","2025-07-09T17:50:30.110Z","2025-12-03T16:06:32.163Z","cat-life","ネコのいる暮らし",{"id":152,"createdAt":153,"updatedAt":154,"publishedAt":154,"revisedAt":154,"title":155,"content":156,"tags":157,"is_no_index":46},"u6nouw78z6k","2026-03-27T09:42:30.473Z","2026-03-27T10:05:58.634Z","Mewkの認証のおはなし","\u003Ch1 style=\"text-align: start\" id=\"h8d027c8ed3\">はじめに\u003C/h1>\u003Cp style=\"text-align: start\">どうも、わたしです。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://mq1.dev/entry/ql0n4uqo0fo\">前回の記事\u003C/a>では、Mewkのアーキテクチャやインフラ構成、OGP画像生成、モデレーションまわりの話を書きました。「まあ自分の記録として残しておければいいか」くらいの温度感で書いたもので、読んでくれる人がいるだけで御の字だと思っていましたが、思っていたより反応をもらえました。個人開発者が書く技術記事なんて、よほどのことがない限り誰にも読まれないのが常ですし、多くはなかったですが、それでも自分の想定を超えていたのは素直に嬉しかったです。\u003C/p>\u003Cp style=\"text-align: start\">その中で、認証まわりの話をもう少し詳しく聞きたいという声を何人かからいただきました。前回の記事でMiAuthトークンの暗号化やNuxt側へのロジック集約について軽く触れていたのが引っかかった方がいたようで、続きを書いてほしいというリクエストをもらいました。書くきっかけをもらえたので、今回はその認証基盤に絞って書くことにします。\u003C/p>\u003Cp style=\"text-align: start\">認証まわりは地味です。ユーザから見えるものではないし、うまく動いていても誰も褒めてくれません。しかし、サービスを真っ当に運営するためには、ある程度は考えなければいけない部分です。\u003C/p>\u003Cp style=\"text-align: start\">書いてみると、思いのほか細かい判断が積み重なっていて、自分でも整理になりました。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6f17e1addd\">MiAuthトークンを直接扱いたくなかった\u003C/h1>\u003Cp style=\"text-align: start\">MewkはMiAuthでユーザを認証します。詳細は省きますが、MiAuthでは一連の認証フローを完了すると、Misskeyからアクセストークンを取得することができます。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">https://misskey-hub.net/ja/docs/for-developers/api/token/miauth/\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">ここで最初に決めたのが、このアクセストークンをクライアントに一切渡さないという方針です。\u003C/p>\u003Cp style=\"text-align: start\">Misskey向けのアプリケーションでよく見かける実装として、MiAuthで取得したアクセストークンをそのままSPAに持たせ、APIリクエストのたびにバックエンド側で検証するというパターンがあります。実装としては確かにシンプルです。しかしながら、この設計には問題があると私は考えています。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンを「サービス側が発行・管理しているもの」として扱っていない点です。サービス側でこのトークンを失効させる手段がなく、ユーザが自らMisskeyの設定画面から認可を取り消す以外に無効化できません。もし何らかの経緯でトークンが漏洩した場合、サービスとしては当然何もできません。サービス側でのRevoke手段を持たない認証設計は、失効対応が必要な場面で致命的になると考えられます。\u003C/p>\u003Cp style=\"text-align: start\">加えて、多くのユーザはアクセストークンが何であるかを理解していないという現実もあります。MewkがMiAuthで要求する権限スコープは\u003Ccode>read:account\u003C/code>, \u003Ccode>write:notes\u003C/code>, \u003Ccode>write:notifications\u003C/code>, \u003Ccode>read:drive\u003C/code>, \u003Ccode>write:drive\u003C/code>の5つで、これだけあればノートの投稿やドライブへのアクセスといった、Mewkが必要とする範囲の操作は全てできます。そして当然ながら、それ以外にもかなり色々なことができてしまいます。そういうトークンをクライアントに持たせ、ユーザに気づかれないまま扱う設計は、とても設計として筋が悪い。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthフローが完了した時点でバックエンド側のみでトークンを受け取り、Mewkが独自に発行したJWTアクセストークンとリフレッシュトークンをクライアントに返すという構成にしました。Misskeyトークンはバックエンド内に閉じ込め、クライアントはMewkのJWTだけを使う。リフレッシュトークンをDBで管理することで、サービス側からいつでも全セッションを無効化できます。sidebaseのLocal実装をそのまま使いつつ、肝心な部分は全てバックエンド側で握る感じです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/miauth/callback.post.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">\n// MiAuthフロー完了時点でMisskeyからトークンを受け取る\nconst response = await $fetch&lt;{ ok: boolean, token?: string, user?: MisskeyUser }&gt;(checkUrl, {\n  method: &apos;POST&apos;,\n});\n\n// Misskeyトークンは暗号化してDBに保存\nconst encryptedAccessToken = encryptMiAuthToken(response.token);\nconst user = await prisma.users.upsert({ ... });\n\n// Mewk独自のJWTとリフレッシュトークンを発行してクライアントへ\nconst jwt = await signJWT({ userId: user.id });\nconst refreshToken = await generateRefreshToken(user.id);\n\nsetSessionCookies(event, { accessToken: jwt, refreshToken });\n\nreturn { token: jwt, refreshToken, ... };\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">JWTはHS256アルゴリズム、有効期限1時間の短寿命トークンです。\u003Ccode>jose\u003C/code>ライブラリを使ってサーバ側で署名・検証しており、issuer/audienceのクレームも設定しています。\u003C/p>\u003Cp style=\"text-align: start\">勘の良い方ならここで一つ疑問が生まれるかもしれません。\u003C/p>\u003Cp style=\"text-align: start\">JWTはステートレスであるという前提なのに、「サービス側からいつでも全セッションを無効化できる」と言えるのはなぜか、という話です。\u003C/p>\u003Cp style=\"text-align: start\">基本的に、JWTの検証はDBを必要としません。署名が正しく、有効期限内であれば、それだけで有効なトークンとして扱われます。つまり、一度発行したJWTをサーバ側から即座に無効化する方法は、仕様上原則として存在しません。ブロックリストをDBやKVに持たせてJWT検証のたびにチェックするという実装も可能ですが、そうするとリクエストごとにストア参照が発生し、ステートレスであるJWT本来の旨味が半減してしまいます。\u003C/p>\u003Cp style=\"text-align: start\">Mewkでは、この問題をJWTの有効期限を短く保つことで許容しています。アクセストークンの有効期限は1時間です。ユーザのログアウトや全セッション無効化(モデレーションに基づく利用制限、Misskeyトークン失効検知など)の操作は、JWTではなくリフレッシュトークンをDB上でrevokeすることで実現します。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト\nexport async function revokeRefreshToken(token: string): Promise&lt;void&gt; {\n  const tokenHash = hashToken(token);\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\n\n// 全セッション無効化\nexport async function revokeAllUserRefreshTokens(userId: string): Promise&lt;void&gt; {\n  await prisma.refreshToken.updateMany({\n    where: { userId, revokedAt: null },\n    data: { revokedAt: new Date(0) },\n  });\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを失効させれば、次のトークン更新のタイミングでセッションが復元できなくなり、実質的にログアウトが完了します。言い換えると、即時の無効化ではなく無操作時で最長1時間(実質10-30分程度)の猶予ウィンドウを持つ無効化という設計です。\u003C/p>\u003Cp style=\"text-align: start\">1時間の猶予はトレードオフの結果です。ブロックリスト方式にすれば即時無効化が可能ですが、前述の通り、全APIリクエストにDB/KVアクセスが加わります。Cloudflare Workers上でエッジのレイテンシを活かしたいという方針と、通常のユーザ体験において最長1時間の猶予が実害になるケースは(おそらく)ほぼないという判断から、今の設計に落ち着いています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h8594e013d3\">httpOnly Cookie\u003C/h1>\u003Cp style=\"text-align: start\">当初はトークンの管理をhttpOnly属性付きのCookieで統一しようとしていました。ただ、これについては少し補足が必要です。\u003C/p>\u003Cp style=\"text-align: start\">httpOnly Cookieに対してよく言われる「JavaScriptから読めないのでXSSに強い」というのは、正確ではあるけれど文脈を省きすぎた主張だとわたしは思っています。XSSが成立した時点で、攻撃者は\u003Ccode>credentials: &apos;include&apos;\u003C/code>を付けたfetchリクエストを送るだけで、httpOnly Cookieをそのまま乗せた状態で同一オリジンに任意のAPIリクエストを投げられます。「JavaScriptからCookieの値が読めない」と「Cookieが悪用できない」は全く別の話で、前者が達成されていても後者は保証されません。XSSが実現した時点でできることはいくらでもありますし、少なくとも私は悪いことを思いついてしまいます。\u003C/p>\u003Cp style=\"text-align: start\">正しい理解は、セキュリティは多層防御の文脈に依存するものであり、httpOnly Cookieはその内の一層に過ぎないということです。localStorageにトークンを保管するよりはhttpOnly Cookieの方が攻撃面が狭い、という程度の話であって、httpOnly Cookieさえ使えば安全というわけではありません。XSSが刺さった時点で無意味になるのはどちらも同じで、根本的な対策はXSSを作り込まないことです。\u003C/p>\u003Cp style=\"text-align: start\">こういう誤解を招きやすい主張が広まっているせいで、httpOnly Cookieを使えば安全という誤った安心感を持つ開発者が少なくありません。まあ、それはさておき。\u003C/p>\u003Cp style=\"text-align: start\">いずれにせよ、全てのトークンをhttpOnly Cookieで管理するという方針はsidebaseのLocal provider実装との相性問題で断念することになりました。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ca href=\"https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\">https://github.com/sidebase/nuxt-auth/blob/33873aa356abdd2d52ab1b6b60930fa09005ef72/src/runtime/composables/local/useAuthState.ts#L30-L114\u003C/a>\u003C/p>\u003Cp style=\"text-align: start\">sidebaseのLocal providerはアクセストークンとリフレッシュトークンをそれぞれ\u003Ccode>useCookie()\u003C/code>で読み書きしており、\u003Ccode>useAuth().refreshToken\u003C/code>のようにComposable経由でJavaScriptから値にアクセスできる設計になっています。httpOnly属性を付けてしまうと\u003Ccode>useCookie()\u003C/code>でCookieの値が取得できなくなるため、リフレッシュトークンのローテーションが機能しなくなってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">微妙だとは思うのですが、ここでは許容することとしています。\u003C/p>\u003Cp style=\"text-align: start\">また、現在の構成では、サーバ側のCookie操作(\u003Ccode>setSessionCookies\u003C/code>)でsidebaseのコンフィグ(Cookie名・maxAge・secure属性等)をそのまま引き継ぎ、サーバとクライアントで同じCookie設定が使われることを保証しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth/sessionCookies.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">function getLocalProviderConfig(): LocalProviderConfig {\n  const provider = useRuntimeConfig().public.auth.provider as LocalProviderConfig;\n  if (provider.type !== &apos;local&apos;) throw new Error(&apos;Local auth provider is required&apos;);\n  return provider;\n}\n\nfunction buildSidebaseCookieOptions(config: LocalCookieConfig): CookieSerializeOptions {\n  return {\n    path: &apos;/&apos;,\n    maxAge: config.maxAgeInSeconds,\n    sameSite: config.sameSiteAttribute,\n    secure: config.secureCookieAttribute,\n    domain: normalizeDomain(config.cookieDomain),\n    httpOnly: config.httpOnlyCookieAttribute,\n  };\n}\n\nexport function setSessionCookies(event: H3Event, session: { accessToken: string; refreshToken?: string | null }): void {\n  const provider = getLocalProviderConfig();\n  setCookie(event, provider.token.cookieName, session.accessToken, buildSidebaseCookieOptions(provider.token));\n  // ...\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">Cookieの属性は一元管理しているため、バックエンド側でCookieを書く際も同じ設定が自動的に適用されます。httpOnly属性で全てを閉じる設計にはできませんでしたが、冒頭に書いた通りそれが全ての解決策にはならないことも事実で、最終的な妥協点としては許容できる範囲であると考えます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"ha9d0cf4895\">MiAuthトークン\u003C/h1>\u003Cp style=\"text-align: start\">前回の記事でも少しだけ言及しましたが、MisskeyのアクセストークンをそのままDBに平文で保存するのは論外です。万が一DBの内容が流出した場合、全ユーザのMisskeyアカウントに対して任意の操作が可能になってしまいます。\u003C/p>\u003Cp style=\"text-align: start\">そこで、MiAuthで取得したトークンはAES-256-GCMで暗号化してDBに保存しています。\u003C/p>\u003Cfigure>\u003Cimg src=\"https://images.microcms-assets.io/assets/3aba23b5bd6f4b79800a0305d0e4f8aa/bba3194de09d4fbba244eb6664d20264/image.png\" alt=\"\" width=\"953\" height=\"84\">\u003C/figure>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">const ALGORITHM = &apos;aes-256-gcm&apos;;\nconst IV_LENGTH = 12;\nconst TAG_LENGTH = 16;\nconst VERSION_PREFIX = &apos;mewk-miauth:v1&apos;;\n\nexport function encryptMiAuthToken(token: string): string {\n  const key = getEncryptionKey();\n  const iv = randomBytes(IV_LENGTH);\n  const cipher = createCipheriv(ALGORITHM, key, iv);\n  const encrypted = Buffer.concat([cipher.update(token, &apos;utf8&apos;), cipher.final()]);\n  const tag = cipher.getAuthTag();\n\n  return [VERSION_PREFIX, toBase64Url(iv), toBase64Url(tag), toBase64Url(encrypted)].join(&apos;:&apos;);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">暗号化されたトークンは\u003Ccode>mewk-miauth:v1:&lt;iv&gt;:&lt;tag&gt;:&lt;ciphertext&gt;\u003C/code>という形式で保存されます。GCMモードを採用しているのは認証付き暗号(AEAD)であるためで、改ざん検知が組み込まれています。ivはリクエストごとに\u003Ccode>randomBytes\u003C/code>で生成するため、同じトークンを暗号化しても毎回異なる暗号文になります。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>mewk-miauth:v1\u003C/code>というPrefixを付けているのは後方互換性のためです。将来的にアルゴリズムや鍵長を変える必要が生じた場合、Prefixのバージョンで判別して適切な復号ロジックに分岐させることを想定しています。AES-256-GCMのまま運用し続けても問題はないですが、暗号プリミティブも長い目で見れば更新が必要になるでしょうし、バージョンを明示する習慣をつけておくだけで、後の改修がだいぶ楽になるはずです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/miauthToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export function isEncryptedMiAuthToken(value: string): boolean {\n  return value.startsWith(`${VERSION_PREFIX}:`);\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">復号は\u003Ccode>resolveStoredMiAuthToken()\u003C/code>という関数にまとめており、DBから取得したトークンが暗号化済みかどうかをPrefixで判別してから復号します。暗号化済みでない場合はその旨を記録して処理を継続します。将来的にバージョンを上げる際も、このPrefix判定を拡張するだけで移行ロジックを組めるはずです。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h84ff7cb54a\">リフレッシュの重複\u003C/h1>\u003Cp style=\"text-align: start\">アクセストークンの有効期限は1時間です。これを自動で延長するために、sidebaseのセッションリフレッシュ機能を使っています。\u003C/p>\u003Cp style=\"text-align: start\">ここで地味に厄介なのが、複数タブで同時にリフレッシュが走る可能性です。\u003C/p>\u003Cp style=\"text-align: start\">ユーザが同じアカウントで複数タブを開いている状態でリロードしたり、アクセストークンが期限切れになると、各タブが独立してリフレッシュリクエストを飛ばします。リフレッシュのたびにリフレッシュトークンをローテーションしているため、最初のリクエストが成功して旧トークンが無効化された直後に、別タブの遅延リクエストが同じ旧トークンで来ると弾かれてしまい、該当するタブに引き摺られるようにログアウトさせられることになります。\u003C/p>\u003Cp style=\"text-align: start\">この問題に対して、二段構えで対応しています。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"h3336b53f2d\">クライアント\u003C/h2>\u003Cdiv data-filename=\"./packages/app/app/utils/deduplicatedAuthRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let inflightRefresh: Promise&lt;unknown&gt; | null = null;\n\nexport function runDeduplicatedAuthRefresh(refresh: () =&gt; Promise&lt;unknown&gt;): Promise&lt;unknown&gt; {\n  if (inflightRefresh) return inflightRefresh;\n\n  // 同時に走るrefreshを1本にまとめる\n  inflightRefresh = refresh().finally(() =&gt; {\n    inflightRefresh = null;\n  });\n\n  return inflightRefresh;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">モジュールスコープの変数でin-flightなPromiseを保持し、すでにリフレッシュが走っているなら同じPromiseを返します。同じタブ内で複数のリフレッシュトリガーが走っても(ウィンドウフォーカス復帰・定期実行・ミドルウェア等)、リクエストは1本しか飛びません。\u003C/p>\u003Cp style=\"text-align: start\">これをsidebaseのカスタムリフレッシュハンドラとして差し込んでいます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/utils/DeduplicatedRefreshHandler.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">class DeduplicatedRefreshHandler implements RefreshHandler {\n  init(): void {\n    this.auth = useAuth();\n    document.addEventListener(&apos;visibilitychange&apos;, this.boundVisibilityHandler, false);\n\n    // ページロード時にリフレッシュトークンがあればセッション復元\n    if (this.auth.refreshToken.value &amp;&amp; !this.auth.data.value) {\n      runDeduplicatedAuthRefresh(this.auth.refresh);\n    }\n\n    // 55分ごとに定期リフレッシュ(アクセストークン期限1時間の直前)\n    this.refetchIntervalTimer = setInterval(() =&gt; {\n      const auth = this.getRefreshableAuth();\n      if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n    }, intervalTime);\n  }\n\n  visibilityHandler(): void {\n    // タブが前面に戻ってきた時もリフレッシュを試みる\n    if (document.visibilityState !== &apos;visible&apos;) return;\n    const auth = this.getRefreshableAuth();\n    if (auth) runDeduplicatedAuthRefresh(auth.refresh);\n  }\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>visibilitychange\u003C/code>イベントを購読しているのは、ブラウザのサスペンドや別タブへの切替から復帰した際にセッションが失効している可能性が想定されるためです。ブラウザ/PWAを長時間バックグラウンドに置いていた場合などでも、タブに戻ってきた瞬間にリフレッシュが走ります。\u003C/p>\u003Ch2 style=\"text-align: start\" id=\"ha2cc149486\">バックエンド\u003C/h2>\u003Cp style=\"text-align: start\">クライアント側の重複排除だけでは不十分で、異なるタブ(=別のモジュールスコープ)からのリクエストは防げません。そこでサーバ側でも対策しています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function verifyRefreshToken(token: string): Promise&lt;{ userId: string } | null&gt; {\n  const tokenHash = hashToken(token);\n  const refreshToken = await prisma.refreshToken.findUnique({ where: { tokenHash } });\n\n  if (!refreshToken) return null;\n\n  // 無効化済みトークンは拒否\n  if (refreshToken.revokedAt) {\n    const GRACE_PERIOD_MS = 30_000;\n    if (Date.now() - refreshToken.revokedAt.getTime() &gt; GRACE_PERIOD_MS) {\n      return null;\n    }\n  }\n\n  if (refreshToken.expiresAt &lt; new Date()) return null;\n\n  return { userId: refreshToken.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">リフレッシュトークンを無効化(\u003Ccode>revokedAt\u003C/code>を記録)してから30秒以内であれば、同じトークンでのリクエストを許容します。これにより、複数タブが同じ旧トークンでほぼ同時にリフレッシュを要求してきた場合でも、全てのタブが新しいアクセストークンを受け取れます。\u003C/p>\u003Cp style=\"text-align: start\">ただし、意図的なセッション失効(ログアウトや不正なトークン使用への対応)ではグレースピリオドをバイパスする必要があります。その場合は\u003Ccode>revokedAt\u003C/code>に\u003Ccode>new Date(0)\u003C/code>を設定することで、前述の30秒を許容する条件を満たせないようにしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">// ログアウト等の強制無効化\nawait prisma.refreshToken.updateMany({\n  where: { tokenHash, revokedAt: null },\n  data: { revokedAt: new Date(0) },\n});\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">また、リフレッシュ処理の実装では新トークンの発行を先に行い、旧トークンの無効化を後に行っています。順序が逆だとDB障害のタイミングによっては旧トークンが無効化されたが新トークンが発行されていない状態になり、ユーザが強制ログアウトされる可能性が考えられます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/refreshToken.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function rotateRefreshToken(oldToken: string, userId: string): Promise&lt;string&gt; {\n  // 新トークンを先に発行\n  // NOTE: DB障害でログアウトされないよう順序を保証\n  const newToken = await generateRefreshToken(userId);\n\n  // 旧トークンを無効化\n  await prisma.refreshToken.updateMany({\n    where: { tokenHash: oldTokenHash, revokedAt: null },\n    data: { revokedAt: new Date() },\n  });\n\n  return newToken;\n}\u003C/code>\u003C/pre>\u003C/div>\u003Ch1 style=\"text-align: start\" id=\"h2f06bf172a\">\u003Cstrong>middlewareで割り込みトークン更新\u003C/strong>\u003C/h1>\u003Cp style=\"text-align: start\">認証が必要なページへのアクセス時に、トークンの状態に応じてリフレッシュや認証ページへのリダイレクトを行うのがNuxtのルートミドルウェアです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/middleware/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export default defineNuxtRouteMiddleware(async (to) =&gt; {\n  if (import.meta.server) {\n    // SSR時はセッションAPIを直接叩いて検証\n    const { data, error } = await useFetch(&apos;/api/v1/auth/session&apos;);\n    if (error.value || !data.value?.user) {\n      return redirectToSignIn();\n    }\n    return;\n  }\n\n  const { status } = useAuth();\n  const { waitForAuthReady, restoreSessionWithRetry, withSessionRestoreOverlay } = useDeduplicatedRefresh();\n\n  if (status.value === &apos;authenticated&apos;) return;\n\n  return withSessionRestoreOverlay(async () =&gt; {\n    await waitForAuthReady();\n    if (status.value === &apos;authenticated&apos;) return;\n\n    // 明示的ログアウト後はリフレッシュをスキップ\n    if (sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)) {\n      return redirectToSignIn();\n    }\n\n    const restored = await restoreSessionWithRetry();\n    if (restored) return;\n\n    return redirectToSignIn();\n  });\n});\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">いくつか細かい工夫があります。\u003C/p>\u003Cp style=\"text-align: start\">まず\u003Ccode>waitForAuthReady()\u003C/code>で、sidebaseの初期ロード中(\u003Ccode>status === &apos;loading&apos;\u003C/code>)が完了するのを最大5秒待ちます。ページロード直後はsidebaseがセッションを確認している途中であることが多く、\u003Ccode>loading\u003C/code>状態を無視してリダイレクトしてしまうとちらつきが発生します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>waitForAuthReady()\u003C/code>が完了しても\u003Ccode>authenticated\u003C/code>でなかった場合、リフレッシュを試みます。ここでも\u003Ccode>restoreSessionWithRetry()\u003C/code>を挟んでおり、最大3回, 500ms間隔でリトライします。モバイル環境等のネットワークが不安定な場合など、一発でリフレッシュが成功しないことがあるためです。\u003C/p>\u003Cdiv data-filename=\"./packages/app/app/composables/useDeduplicatedRefresh.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">async function restoreSessionWithRetry(options: RestoreSessionOptions = {}): Promise&lt;boolean&gt; {\n  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; // 3\n\n  for (let attempt = 0; attempt &lt;= maxRetries; attempt++) {\n    try {\n      await deduplicatedRefresh();\n    } catch { }\n\n    if (status.value !== &apos;authenticated&apos;) {\n      await Promise.race([\n        until(status).toBe(&apos;authenticated&apos;),\n        sleep(authenticatedWaitMs), // 最大1秒待機\n      ]);\n    }\n\n    if (status.value === &apos;authenticated&apos;) return true;\n    if (attempt &lt; maxRetries) await sleep(retryDelayMs);\n  }\n\n  return false;\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>withSessionRestoreOverlay()\u003C/code>はUIのちらつき防止のためのラッパーで、セッション復元中はオーバーレイカウントをインクリメントしてローディング状態を表現しています。\u003C/p>\u003Cp style=\"text-align: start\">なお、\u003Ccode>sessionStorage.getItem(&apos;mewk:explicit-logout&apos;)\u003C/code>のチェックは、ユーザが自分でログアウトした後にリフレッシュトークンが残っていても再ログインしてしまう問題を防ぐためです。明示的なログアウト操作時にはsessionStorageにフラグを立て、ミドルウェアがこれを検知した場合はリフレッシュをスキップして\u003Ccode>/auth/signin\u003C/code>にリダイレクトします。ちなみに、sessionStorageはタブを閉じると消えるため、以降のセッションでは正常にリフレッシュが走ります。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h6181bfeeb6\">SSR時のセッション処理\u003C/h1>\u003Cp style=\"text-align: start\">SSRが絡むと少し複雑になります。サーバサイドレンダリング時には\u003Ccode>useAuth()\u003C/code>が使えないため、セッションAPIを直接叩いてセッションを確認します。\u003C/p>\u003Cp style=\"text-align: start\">\u003Ccode>/api/v1/auth/session\u003C/code>のエンドポイントでは、アクセストークンがCookieに存在する場合はそれで認証し、存在しない場合はリフレッシュトークンで1回だけセッション復元を試みます。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/api/v1/auth/session.get.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">let payload: { userId: string };\n\ntry {\n  payload = await requireAuthWithoutBanCheck(event);\n} catch (error) {\n  // アクセストークンが無ければリフレッシュトークンで復元\n  const refreshToken = getRefreshTokenFromCookies(event);\n  if (!refreshToken) throw error;\n\n  const restoredSession = await restoreSessionFromRefreshToken(event);\n  payload = { userId: restoredSession.userId };\n}\n\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">\u003Ccode>restoreSessionFromRefreshToken()\u003C/code>はセッション復元と同時に新しいアクセストークンとリフレッシュトークンを発行し、Cookieをセットします。つまりSSR時にもトークンのローテーションが透過的に行われるため、ユーザは意識することなく認証状態が維持されます。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"hbdedd2bd61\">Misskeyトークン失効の検知\u003C/h1>\u003Cp style=\"text-align: start\">Misskeyのアクセストークンは、ユーザが連携アプリの認可を取り消したり、アカウントが凍結された場合に無効になります。この場合、ユーザには継続して有効なMewkの発行するJWTが手元にありますが、バックエンドが実際にMisskey APIを叩こうとすると失敗します。\u003C/p>\u003Cp style=\"text-align: start\">この不整合を検知するため、セッション確認のたびにMisskeyの\u003Ccode>/api/i\u003C/code>へのアクセス確認を行っています。ただし毎回叩くとレイテンシが悪化する可能性があるため、KVを使って5分間キャッシュしています。\u003C/p>\u003Cdiv data-filename=\"./packages/app/server/utils/auth.ts\">\u003Cpre>\u003Ccode class=\"language-ts\">export async function checkMisskeyTokenValidity(\n  event: H3Event,\n  userId: string,\n  domain: string,\n  accessToken: string,\n  options: MisskeyTokenCheckOptions,\n): Promise&lt;void&gt; {\n  const kv = getKV(event);\n  const cacheKey = `misskey-valid:${userId}`;\n  const cached = await kv.get(cacheKey);\n  if (cached) return;\n\n  // タイムアウト付きでMisskeyに問い合わせ\n  const result = await Promise.race([fetchPromise, timeoutPromise]);\n\n  if (status === 401 || status === 403) {\n    // トークン無効 全セッションを強制破棄\n    await revokeAllUserRefreshTokens(userId);\n    throw createError({ statusCode: 401, statusMessage: &apos;MISSKEY_SESSION_EXPIRED&apos;, ... });\n  } else if (status &gt;= 200 &amp;&amp; status &lt; 300) {\n    await kv.put(cacheKey, &apos;1&apos;, { expirationTtl: 300 });\n  }\n  // 5xx\n}\u003C/code>\u003C/pre>\u003C/div>\u003Cp style=\"text-align: start\">タイムアウトは1500msに設定していて、Misskeyのレスポンスが遅い場合は検証をスキップします。不整合が発生したからといって、外部サービスの応答待ちでユーザのリクエストを阻害するのは設計として微妙すぎるためです。\u003C/p>\u003Cp style=\"text-align: start\">Misskeyがダウンしているだけで全ユーザがセッションを失うような事態を避けるため、5xx系のレスポンスやタイムアウトは正常扱いしてスルーしています。明確に401/403のレスポンスが返った場合のみ、ユーザの全リフレッシュトークンを無効化し、ログアウト理由をフロントに伝えるために非httpOnlyのCookie(\u003Ccode>mewk_logout_reason\u003C/code>)を短命で立てています。\u003C/p>\u003Ch1 style=\"text-align: start\" id=\"h1afe451c43\">さいごに\u003C/h1>\u003Cp style=\"text-align: start\">振り返ると、認証まわりで一番頭を使ったのはrefreshの競合問題です。「複数タブで同時に開いてたら」「ブラウザのサスペンドから復帰したら」「ネットワークが不安定だったら」といった微妙なエッジケースを一つひとつ潰していくのは、手を抜けない部分でした。\u003C/p>\u003Cp style=\"text-align: start\">セキュリティまわりの設計については、「ライブラリを使っているから大丈夫」という発想を極力持たないようにしました。sidebaseを使っていますが、MiAuthトークンをクライアントに渡さない・DB保存時に暗号化する・revoke手段をサービス側で持つ、といった判断はライブラリに任せられるものではありません。ライブラリはあくまで実装の補助であって、設計上の責任まで肩代わりしてくれるわけではありません。ここを混同すると、ライブラリのデフォルト挙動に乗っかったまま後から気づけない穴を作ることになります。\u003C/p>\u003Cp style=\"text-align: start\">書いていて改めて思いましたが、認証の設計というのはこうすれば完璧という答えがなく、トレードオフの連続でした。JWTの即時revoke問題もhttpOnly Cookieの限界も、どこかで折り合いをつけなければいけない。大事なのは、そのトレードオフが何なのかを理解した上で判断することで、何も考えずにデフォルトに従うことではないと考えます。\u003C/p>\u003Cp style=\"text-align: start\">今後も機能追加や仕様変更の中で認証まわりはじわじわ変わっていくと思いますが、基本的な設計の方針は変えるつもりはありませんし、Misskeyのアクセストークンをバックエンドに閉じ込め管理するという構造は、使い続けていてもそれほど不便を感じていませんし、むしろ正解だったと思っています。\u003C/p>\u003Cp style=\"text-align: start\">それでは。\u003C/p>",[158,159,160,161,162],{"id":34,"createdAt":35,"updatedAt":36,"publishedAt":35,"revisedAt":36,"slug":37,"name":38},{"id":104,"createdAt":105,"updatedAt":106,"publishedAt":105,"revisedAt":106,"slug":107,"name":108},{"id":19,"createdAt":20,"updatedAt":21,"publishedAt":20,"revisedAt":21,"slug":22,"name":23},{"id":65,"createdAt":66,"updatedAt":67,"publishedAt":66,"revisedAt":67,"slug":68,"name":69},{"id":90,"createdAt":91,"updatedAt":92,"publishedAt":91,"revisedAt":92,"slug":93,"name":94},69,0,12,1783975282758]