[{"data":1,"prerenderedAt":692},["ShallowReactive",2],{"content-/unpublished-leak-prevention":3,"all-pages-for-dir":690,"og-image-/unpublished-leak-prevention":691},{"id":4,"title":5,"body":6,"category":671,"description":672,"extension":673,"meta":674,"navigation":550,"ogImage":675,"path":676,"project_name":677,"published":678,"publishedAt":679,"seo":680,"stem":681,"tags":682,"todo":675,"unpublished":678,"updatedAt":679,"__hash__":689},"pages/2026-05/2026-05-22/unpublished-leak-prevention.md","Nuxt Content サイトに非公開フラグを導入し、HTML・サイトマップ・payload からの漏えいを遮断するまで",{"type":7,"value":8,"toc":648},"minimark",[9,14,18,55,71,75,84,124,127,135,162,168,172,183,207,229,246,250,293,297,304,307,339,357,364,380,384,387,413,425,431,443,449,463,473,477,486,505,509,529,536,539,593,597,631,634,645],[10,11,13],"h2",{"id":12},"きっかけ-nvidia-q1-fy27-決算記事を即引っ込めたかった","きっかけ — NVIDIA Q1 FY27 決算記事を即引っ込めたかった",[15,16,17],"p",{},"朝、NVIDIA Q1 FY27 決算電話会議の全文書き起こしを記事として書き終え、エディタを閉じる前に出典の Motley Fool のページに戻った。利用規約の欄に「事前の書面同意なしに複製・転写することを禁ずる」と書いてある。記事の本文は丸ごと書き起こしと翻訳に近い構造で、このまま公開し続けるとアウトに振れる。",[15,19,20,21,25,26,29,30,33,34,37,38,41,42,54],{},"最初に思いついたのは「frontmatter に ",[22,23,24],"code",{},"published: false"," を入れる」だった。試したところ、",[22,27,28],{},"content.config.ts"," の Zod スキーマで ",[22,31,32],{},"published: z.boolean()"," を必須にしていたせいで、過去の記事 1000 本すべてに ",[22,35,36],{},"published: true"," を打ち直さないと SQLite 側で ",[22,39,40],{},"published=false"," 扱いになる、と判明した。Nuxt Content v3 が内部で持つ SQLite は ",[43,44,45,46,49,50,53],"strong",{},"「frontmatter に値が無い列も ",[22,47,48],{},"false","/",[22,51,52],{},"null"," で埋める」"," 挙動なので、追加した瞬間に既存記事が全部消える。",[15,56,57,58,61,62,49,65,67,68,70],{},"意味を逆にした。",[22,59,60],{},"unpublished: true"," をopt-in で立てる方式に切り替えた。未指定 = ",[22,63,64],{},"undefined",[22,66,52],{}," = 公開、明示 ",[22,69,60],{}," のみ非公開。これなら 1000 本に触らなくていい。",[10,72,74],{"id":73},"最初の実装で-codex-に致命的指摘を3件もらった","最初の実装で Codex に致命的指摘を3件もらった",[15,76,77,79,80,83],{},[22,78,60],{}," を立て、",[22,81,82],{},"/blog"," から弾けば終わりだと思っていた。Codex に計画書をレビューさせたら3件の致命傷が返ってきた。",[85,86,87,101,118],"ol",{},[88,89,90,96,97,100],"li",{},[43,91,92,95],{},[22,93,94],{},"/_raw/\u003Cpath>.md"," から本文がダダ漏れする","。Nitro hook で content の Markdown を ",[22,98,99],{},"dist/_raw/"," にコピーする経路がある。フラグを立てても本文 md は dist に乗ったままで、URL を直叩きすれば全文が読める。",[88,102,103,113,114,117],{},[43,104,105,108,109,112],{},[22,106,107],{},"/search"," と ",[22,110,111],{},"/project-timeline"," が unpublished をフィルタしていない","。タイトルと description が公開 payload に載るので、検索ページから記事タイトルだけ拾える。",[22,115,116],{},"[...slug].vue"," のディレクトリ一覧クエリも同じ穴を持っていた。",[88,119,120,123],{},[43,121,122],{},"dist 側の閉塞確認が無い","。フラグを立てた直後にビルドして「本当に出てない」を機械的に証明する手段が無い。回帰したら気づけない。",[15,125,126],{},"朝の「フラグ立てて終わり」が、夕方には「Phase -1: 漏えい遮断機構を先に作る」に膨らんだ。",[10,128,130,131,134],{"id":129},"phase-11-_raw-漏えいの遮断","Phase -1.1 — ",[22,132,133],{},"_raw"," 漏えいの遮断",[15,136,137,140,141,144,145,147,148,151,152,155,156,108,158,161],{},[22,138,139],{},"apps/web/nuxt.config.ts"," の Nitro hook で、",[22,142,143],{},"content/"," の Markdown を ",[22,146,99],{}," にコピーしている部分（",[22,149,150],{},"copyMarkdownFiles"," 周辺）を Claude Code に修正してもらった。",[22,153,154],{},"isUnpublishedFrontmatter()"," ヘルパを切り出し、",[22,157,150],{},[22,159,160],{},"getContentRoutes","（プリレンダー対象列挙）の両方で除外する。両方を直さないと、片方は md が消えるのにもう片方が HTML を生成してリンクを張る、というちぐはぐが起きる。",[15,163,164,167],{},[22,165,166],{},"verify-raw-files.mjs"," も書き直してもらった。これまでは「content にある md は dist/_raw に存在する」を検証していたが、unpublished は逆に「存在しない」が正しい状態になる。検証スクリプトとフラグの意味を揃えた。",[10,169,171],{"id":170},"phase-12-payload-漏えいの遮断","Phase -1.2 — payload 漏えいの遮断",[15,173,174,175,178,179,182],{},"共通ヘルパ ",[22,176,177],{},"app/utils/article-publish.ts"," に ",[22,180,181],{},"filterPublic()"," を作り、3 ページのフィルタを一箇所に集約した。",[184,185,186,191,196,202],"ul",{},[88,187,188],{},[22,189,190],{},"app/pages/search.vue",[88,192,193],{},[22,194,195],{},"app/pages/project-timeline.vue",[88,197,198,201],{},[22,199,200],{},"app/pages/[...slug].vue"," のディレクトリ一覧クエリ",[88,203,204],{},[22,205,206],{},"app/composables/useBlogArticles.ts",[15,208,209,210,213,214,217,218,221,222,224,225,228],{},"ここで Codex がもう一発刺してきた。「",[22,211,212],{},"computed"," 内の ",[22,215,216],{},"filter"," だと、",[22,219,220],{},"asyncData"," の戻り値（= payload）には unpublished 記事が乗ったまま、クライアント側だけ非表示にする構造になる」。dev でブラウザの DevTools を開いて payload を覗いたら、たしかに nvidia-fy27-q1-earnings のタイトルが JSON に乗っていた。",[22,223,220],{}," の戻り値段階で ",[22,226,227],{},"filterPublic"," を適用するように直し、ブラウザに送る前に消えていることを確認した。",[15,230,231,234,235,237,238,237,240,237,243,245],{},[22,232,233],{},"tests/article-publish.test.ts"," を 5 件追加した。dev で ",[22,236,107],{}," ",[22,239,111],{},[22,241,242],{},"/2026-05/2026-05-22",[22,244,82],{}," を順に開いて、nvidia-fy27 が 0 件であることを目で確認した。",[10,247,249],{"id":248},"phase-13-sitemap-redirects-og画像からの除外","Phase -1.3 — sitemap / redirects / OG画像からの除外",[184,251,252,262,272,282],{},[88,253,254,257,258,261],{},[22,255,256],{},"scripts/generate-sitemap.mjs"," の ",[22,259,260],{},"loadNoindexPaths()"," に unpublished パスを足した（defense-in-depth）",[88,263,264,267,268,271],{},[22,265,266],{},"scripts/generate-redirects.mjs"," の publicFiles 生成段階で unpublished を除外（canonical URL を ",[22,269,270],{},"_redirects"," に流さない）",[88,273,274,277,278,281],{},[22,275,276],{},"nitroConfig.prerender.routes"," から unpublished URL を除外（",[22,279,280],{},"getContentRoutes()"," 内で対応済み）",[88,283,284,285,288,289,292],{},"OG 画像 Worker は別リポジトリだが、",[22,286,287],{},"useOgSignature.ts"," で HMAC 署名必須にしてあり、",[22,290,291],{},"OG_SECRET"," を持たない攻撃者が URL を推測しても OG を取得できないことを確認",[10,294,296],{"id":295},"phase-14-dist-を実際に走査する-postgenerate-検証","Phase -1.4 — dist を実際に走査する postgenerate 検証",[15,298,299,300,303],{},"ロジックで除外しても、何かのリグレッションで再混入する可能性は残る。",[22,301,302],{},"scripts/verify-unpublished-excluded.mjs"," を新規作成して、ビルド後の dist を機械的に走査する。",[15,305,306],{},"検証項目：",[184,308,309,315,320,326,333],{},[88,310,311,314],{},[22,312,313],{},"dist/\u003Cpath>/index.html"," が存在しないこと",[88,316,317,314],{},[22,318,319],{},"dist/_raw/\u003Cpath>.md",[88,321,322,325],{},[22,323,324],{},"dist/sitemap.xml"," に該当 URL が含まれないこと",[88,327,328,329,332],{},"dist 配下の HTML に該当 path への ",[22,330,331],{},"href"," が含まれないこと",[88,334,335,338],{},[22,336,337],{},"dist/_nuxt/"," の JS/JSON バンドルに該当 path が含まれないこと",[15,340,341,342,345,346,257,349,352,353,356],{},"1 件でも失敗したら ",[22,343,344],{},"process.exit(1)","。",[22,347,348],{},"package.json",[22,350,351],{},"postgenerate"," に組み込んで、",[22,354,355],{},"pnpm generate"," の最後に必ず走るようにした。",[10,358,360,361,363],{"id":359},"副産物-claudemd-にpnpm-generate-を毎回走らせるなと書いた","副産物 — CLAUDE.md に「",[22,362,355],{}," を毎回走らせるな」と書いた",[15,365,366,367,369,370,372,373,376,377,379],{},"Phase -1 を作っているあいだ、",[22,368,355],{}," を確認のたびに走らせていたら 10 分 × 数回でセッションを溶かした。1000 本超の記事を SSG で処理するので、build-time-only コード以外は dev＋ユニットテストで先に検証し、",[22,371,355],{}," はリリース直前の 1 回だけ、というルールをプロジェクト直下の ",[22,374,375],{},"CLAUDE.md"," に明文化した。Claude Code に「確認のために ",[22,378,355],{}," を走らせます」と提案させない仕掛けを置いた、という意味でもある。",[10,381,383],{"id":382},"phase-1-19-件への適用","Phase 1 — 19 件への適用",[15,385,386],{},"機構が閉じたところで、既存記事の棚卸しに移った。",[184,388,389,396,407,410],{},[88,390,391,392,395],{},"候補 46 件を ",[22,393,394],{},"memo/2026-05-22/unpublished-candidates-stocks.md"," に dump し、Tier 1〜4 で分類",[88,397,398,399,402,403,406],{},"Tier 1 の 16 件を ",[22,400,401],{},"scripts/mark-unpublished.mjs"," で一括非公開化。idempotent に動くので、すでに ",[22,404,405],{},"unpublished:"," 行がある記事はスキップ、frontmatter が無いファイルも壊さずスキップ",[88,408,409],{},"AMD-OpenAI、NBIS、MRVL、Micron×2、AAOI、Lumentum、Bloom、SNDK、memory-investment、Cerebras、STF、NVDA BofA PT、stock-information×3 を非公開化",[88,411,412],{},"Tier 2（6 件）も個別に判断して非公開化。教育的要素はあるが個別銘柄の売買判断・PT 収集に寄っているもの",[15,414,415,416,419,420,108,422,424],{},"NVIDIA Q1 記事と合わせて 1 → 19 件に拡大した。dev で ",[22,417,418],{},"/blog/unpublished"," を開くと 19 件並び、",[22,421,82],{},[22,423,107],{}," から nvidia-fy27 を含む 18 件がすべて消えている。",[10,426,428,430],{"id":427},"blog-構造の分離",[22,429,82],{}," 構造の分離",[15,432,433,434,436,437,439,440,442],{},"公開記事は ",[22,435,82],{},"、非公開記事は ",[22,438,418],{}," に分けた。",[22,441,418],{}," は dev でだけアクセスできる。本番では 404 を返す。トップページの「最近の記事」セクションの脇にも「ブログ非公開」カードを置き、dev のときだけ表示する。本番ビルドではコンポーネント自体が描画されない。",[10,444,446,448],{"id":445},"pnpm-generate-で見つかった内部リンク漏れ",[22,447,355],{}," で見つかった内部リンク漏れ",[15,450,451,452,454,455,458,459,462],{},"機構を全部閉じたつもりで ",[22,453,355],{}," を走らせ、",[22,456,457],{},"verify-unpublished-excluded.mjs"," に通したら 9 件落ちた。原因は「公開済みの日記から、非公開化した記事へ Markdown リンク ",[22,460,461],{},"[テキスト](/path)"," が残っている」だった。本文 HTML はビルド済みなので、フラグ立てだけでは消えない。",[15,464,465,466,469,470,472],{},"Claude Code に「unpublished フラグを外すか、リンク側をプレーンテキスト化するか」を提案させた。フラグは残したいので、リンク 12 箇所を一括 Edit で、「NVIDIA Q1 FY27 書き起こし」への内部 Markdown リンクをプレーンテキスト ",[22,467,468],{},"NVIDIA Q1 FY27 書き起こし（未公開）"," に置き換えてもらった。再ビルドで ",[22,471,457],{}," が緑になった（なお、この記事自身も説明用にリンク記法を本文へ書いてしまい漏えい源になっていたため、2026-06-01 に同じ要領でプレーンテキスト化した）。",[10,474,476],{"id":475},"vitest-化-markdown-ソース段階で潰す","Vitest 化 — Markdown ソース段階で潰す",[15,478,479,481,482,485],{},[22,480,355],{}," の 10 分を待たないと拾えないバグ、という構造が気持ち悪い。検証スクリプトのうち「公開済み記事から非公開記事への Markdown リンク検出」だけはソースの md だけで判定できるので、ロジックを ",[22,483,484],{},"app/utils/find-leaked-unpublished-links.ts"," に切り出した。",[15,487,488,489,492,493,496,497,500,501,504],{},"純粋関数として、",[22,490,491],{},"ArticleSource[]"," を受け取り ",[22,494,495],{},"LeakedLink[]"," を返す形にした。frontmatter 抽出、unpublished 判定、path 抽出も全部分離。",[22,498,499],{},"tests/unpublished-link-leakage.test.ts"," で実 content/ を流し込むテストを書き、",[22,502,503],{},"pnpm test:run"," で毎回拾えるようにした。これで「フラグを立てた瞬間、リンクが残っていれば数秒で落ちる」流れに変わった。",[10,506,508],{"id":507},"testrun-を-build-に組み込む案-revert","test:run を build に組み込む案 → revert",[15,510,511,512,108,515,257,517,178,520,522,523,525,526,528],{},"ここまで来て「",[22,513,514],{},"measure-deploy.ps1",[22,516,348],{},[22,518,519],{},"prebuild",[22,521,503],{}," を前置きすれば、漏えいリンクを残したまま ",[22,524,355],{}," が走ることは無くなる」と思いついた。組み込んでみたら、unpublished とは別件のテスト失敗が 22 件あり、",[22,527,503],{}," が常に赤い状態だった。前置きを足しても build が永遠に通らない。",[15,530,531,532,535],{},"一旦 revert。別件のテスト失敗は別記事の計画書（",[22,533,534],{},"memo/2026-05-22/test-failures-recovery-plan"," 系）に切り出し、そちらが緑になってからもう一度組み込む段取りにした。",[10,537,538],{"id":538},"完了報告",[184,540,543,559,565,574,580],{"className":541},[542],"contains-task-list",[88,544,547,552,553,555,556,558],{"className":545},[546],"task-list-item",[548,549],"input",{"checked":550,"disabled":550,"type":551},true,"checkbox"," 1. Vitest ユニットテスト: ",[22,554,233],{},"（5件）と ",[22,557,499],{},"（純粋関数 + integration）を追加、いずれも pass",[88,560,562,564],{"className":561},[546],[548,563],{"disabled":550,"type":551}," 2. Playwright E2E: 純粋関数とビルド時フィルタの変更のためスキップ。dev での手動確認で代替",[88,566,568,570,571,573],{"className":567},[546],[548,569],{"checked":550,"disabled":550,"type":551}," 3. Vitest coverage: ",[22,572,484],{}," を新規作成、純粋関数として完全網羅",[88,575,577,579],{"className":576},[546],[548,578],{"disabled":550,"type":551}," 4. Vitest bench: パフォーマンス重要処理ではないためスキップ",[88,581,583,585,586,588,589,592],{"className":582},[546],[548,584],{"checked":550,"disabled":550,"type":551}," 5. セキュリティ / パフォーマンス / SRE 自己レビュー: ",[22,587,133],{}," 漏えい、payload 漏えい、sitemap、redirects、dist HTML href、",[22,590,591],{},"_nuxt/"," バンドルの 6 経路を遮断。postgenerate の検証スクリプトで回帰検知",[10,594,596],{"id":595},"残タスク明日以降","残タスク（明日以降）",[184,598,600,606,612,621],{"className":599},[542],[88,601,603,605],{"className":602},[546],[548,604],{"disabled":550,"type":551}," Tier 3（17 件）の据え置き記事を再度レビューし、線引きを言語化する",[88,607,609,611],{"className":608},[546],[548,610],{"disabled":550,"type":551}," Tier 4 diary（6 件）を 1 日 5 本ペースで個別判定",[88,613,615,617,618,620],{"className":614},[546],[548,616],{"disabled":550,"type":551}," 別件のテスト失敗 22 件を解消し、",[22,619,503],{}," を build の前置きに戻す",[88,622,624,626,627,630],{"className":623},[546],[548,625],{"disabled":550,"type":551}," CLAUDE.md / ",[22,628,629],{},"content-management"," スキルに「新規記事作成時の公開/非公開判定フロー」を追記",[10,632,633],{"id":633},"今日の学び",[15,635,636,638,639,641,642,644],{},[22,637,60],{}," を立てるだけで漏えいは止まらない。Nuxt Content v3 の SSG では本文 md、payload JSON、sitemap、redirects、dist HTML の ",[22,640,331],{},"、",[22,643,591],{}," の JS バンドルと、6 つの経路すべてを閉じないとどこかから漏れる。Codex のレビューが入らなかったら、フラグだけ立てた状態で「閉じたつもり」になっていた。",[15,646,647],{},"そして「機構を閉じる前に記事を増やすと、増やすほど漏えい面が拡大する」順序の罠を踏みかけた。Phase -1 を先に終わらせて、Phase 1 の 19 件に進めたのは正解だった。",{"title":649,"searchDepth":650,"depth":650,"links":651},"",2,[652,653,654,656,657,658,659,661,662,664,666,667,668,669,670],{"id":12,"depth":650,"text":13},{"id":73,"depth":650,"text":74},{"id":129,"depth":650,"text":655},"Phase -1.1 — _raw 漏えいの遮断",{"id":170,"depth":650,"text":171},{"id":248,"depth":650,"text":249},{"id":295,"depth":650,"text":296},{"id":359,"depth":650,"text":660},"副産物 — CLAUDE.md に「pnpm generate を毎回走らせるな」と書いた",{"id":382,"depth":650,"text":383},{"id":427,"depth":650,"text":663},"/blog 構造の分離",{"id":445,"depth":650,"text":665},"pnpm generate で見つかった内部リンク漏れ",{"id":475,"depth":650,"text":476},{"id":507,"depth":650,"text":508},{"id":538,"depth":650,"text":538},{"id":595,"depth":650,"text":596},{"id":633,"depth":650,"text":633},"dev","Motley Fool 由来のNVIDIA決算記事を非公開化する必要が出たことを起点に、unpublished: true をopt-inで導入。Nuxt Content v3 のSQLite挙動（published未指定もfalse化）を避けるための設計、_raw/sitemap/payload/dist HTML の漏えい遮断機構、検証スクリプトのVitest化、19件への適用、内部リンク12箇所のプレーンテキスト化までを記録する。","md",{},null,"/unpublished-leak-prevention","mdx-playground",false,"2026-05-22T00:00:00.000Z",{"title":5,"description":672},"2026-05/2026-05-22/unpublished-leak-prevention",[683,684,685,686,687,688],"Nuxt Content","unpublished","SSG","漏えい遮断","Vitest","公開フラグ","L_PttVnuIuHRCeB6CNGEyukRnTH9YwQzptPEE7lFQDs",[],"https://log.eurekapu.com/og/blog/unpublished-leak-prevention.png?v=2026-05-22T00%3A00%3A00.000Z&title=Nuxt%20Content%20%E3%82%B5%E3%82%A4%E3%83%88%E3%81%AB%E9%9D%9E%E5%85%AC%E9%96%8B%E3%83%95%E3%83%A9%E3%82%B0%E3%82%92%E5%B0%8E%E5%85%A5%E3%81%97%E3%80%81HTML%E3%83%BB%E3%82%B5%E3%82%A4%E3%83%88%E3%83%9E%E3%83%83%E3%83%97%E3%83%BBpayload%20%E3%81%8B%E3%82%89%E3%81%AE%E6%BC%8F%E3%81%88%E3%81%84%E3%82%92%E9%81%AE%E6%96%AD%E3%81%99%E3%82%8B%E3%81%BE%E3%81%A7&author=Kei%20Komatsu&sig=1cfc09b32786cc57",1782528841752]