作成日: 2026-08-28 / シンオン株式会社
対象: 用意する人(エンジニア・情シス)/ 決める人(経営)/ つくる人(非エンジニア社員)
要旨: 非エンジニアが Cowork で実装 → zip をポータルに上げるだけで、GitHub → Actions → Workers / D1 / R2 に配信される
成果物: 本書(md / html)と 実装一式 synon-apphub-kit.zip
同じフォルダに、社内サービスを非エンジニアに作らせるための資料が 3 本ある。この文書は 4 つ目の方式の設計書であり、既存 3 方式を置き換えるものではない。
| プラン A | プラン B | Cloudflare OS | アプリハブ(本書) | |
|---|---|---|---|---|
| つくる場所 | Codespaces | 本人の Mac(Claude Code) | ブラウザ | Cowork(ブラウザ) |
| GitHub アカウント | 必要 | 不要(Deploy key) | 不要 | 不要 |
| PC への導入 | ゼロ | 30 分/人 | ゼロ | ゼロ |
| ローカルでビルド | あり | あり | なし | なし |
| 本番公開の関門 | PR マージ | PR マージ | なし(共有=即配布) | ポータルの承認ボタン |
| 差分レビュー | できる | できる | 実質不可 | できる(画面上で) |
| 退職者 PC の複製 | なし | 残る(残課題) | なし | なし |
| 月額 | $50〜80 | $13 | $5+AI 従量 | $13 |
アプリハブは「プラン B の配信経路だけを、人手からポータルに置き換えたもの」である。作るもの(Workers + D1 + R2)も、置き場所(GitHub)も、認証(Cloudflare Access)もプラン B と同じ。変えたのは、つくる人と GitHub のあいだに人間が立たなくてよくしたことの一点に尽きる。
| あなたの立場 | 読むところ | 所要 |
|---|---|---|
| 決める人 | 1〜5 章と 24〜25 章 | 15 分 |
| 用意する人(実装者) | 全部 + 実装一式の APPLY.md | 60 分 |
| つくる人 | 6〜13 章だけ。第 III 部は読まなくてよい | 15 分 |
プラン B(claude-code-internal-app-workflow-planb)は動く設計だが、実装を進める中で 4 つの摩擦が残った。アプリハブはこの 4 つを潰すためだけに存在する。
| # | プラン B の残課題 | なぜ痛いか | アプリハブでの解 |
|---|---|---|---|
| 1 | 端末セットアップ 30 分/人 | Node・npm・Claude Code・Deploy key・launchd。1 人増えるたびに情シスが張り付く。社内プロキシ下で npm ci が通るかは実測しないと分からない | PC に何も入れない。Cowork のブラウザだけ。ビルドは GitHub Actions の中でしか起きない |
| 2 | 退職者の Mac にソースの複製が残る | 技術で解けず「端末返却を退職手続きに紐づける」という運用の宿題になっていた | 正本は GitHub にしかない。手元は作業用の写し。Cowork のプロジェクトを消せば終わり |
| 3 | 公開のたびにエンジニアが PR を作る | 「1 回 2 分」は嘘ではないが、待ち時間が発生する。承認者が席にいないと止まる | ポータルの[本番公開]ボタン。GitHub を開かずに承認でき、記録も残る |
| 4 | 配信結果が本人に返らない | gh が使えず Chatwork 通知で代替。失敗時に何が起きたか本人が読めない | ポータルに履歴とエラー本文が出る。Cowork にそのまま貼れる |
プラン B から引き継いで変えないもの:ベースは cf-internal-app-starter(Workers + D1 + R2 + KV + Access)/ Cloudflare の鍵は GitHub Actions にしか置かない / 本番は必ず人が承認する / ブランチは試用と本番を分ける。
常駐プロセス・重いバッチ・独自 DB・社内 LAN 内の機器を叩くもの・数百 MB のファイルを扱うものは作れない。既存資料の判定表でいう D4 に当たるものは対象外。従来どおりエンジニアが作る。
工程は 8 つ。このうちつくる人がやるのは 3 つ(②③④)だけである。
「④ 上げる」と「⑤ 確かめる」のあいだに 2 分の待ちがある。プラン B ではローカルで npm run dev が回るぶん、手元で秒単位に確かめられた。アプリハブはそれを捨て、代わりにつくる人の PC から開発環境を丸ごと消した。この取引を受け入れられるかが、この方式を採るかどうかの分かれ目になる(→ 28 章 撤退基準)。
実装で迷ったときは、この 3 つに戻って決める。
つくる人の手元のフォルダも、アップロードされた zip も、すべて写しである。消えても失われるものは無い。
渡さないもの: Cloudflare の API トークン、Access の設定、wrangler.jsonc、.github/workflows/、本番の DB、他人のアプリ。
app.config.json ただ 1 つ。書けることは allowlist で限定する(→ 17 章)「悪意」への対策ではなく「AI が気を利かせて deploy.yml を書き換える」への対策である。実際、Claude に「デプロイが失敗する」と言えば deploy.yml を直そうとする。それを構造的に禁止する。
手元でビルドできない代わりに、アプリごとに試用環境を常設する。本番と同じ構成・同じ認証・別の DB。
<slug>-stg.apps.synon.co.jp を開いて確かめる。同僚に見てもらうときも同じ URL役割は 4 つ。ポータルの中の役割であって、Cloudflare Access のグループとは別物である点に注意(Access は「この URL を開いてよいか」だけを見る)。
| 役割 | 誰 | ポータルでできること | できないこと |
|---|---|---|---|
| 管理者 | 情シス・エンジニア | アプリ枠の作成/廃止、上限の変更、全アプリの閲覧、担当者の割当 | — |
| つくる人 | 非エンジニア社員 | 自分が担当のアプリの zip ダウンロード/アップロード、試用環境への配信、配信履歴の閲覧 | 本番公開ボタンは押せない。他人のアプリは見えない |
| 承認者 | エンジニア または 部門長 | 担当アプリの本番公開、ロールバック、差分の確認 | アプリ枠の作成・削除 |
| 使う人 | 全社員 | 出来上がったアプリを使う(一覧から開く) | 管理機能は見えない |
承認者を「エンジニア」に固定しない。差分プレビューが画面上に出るので、業務の妥当性だけを見ればよい場合は部門長でよい。ただし data/migrations/ に破壊的な変更(DROP TABLE 等)が含まれる回だけは、エンジニアの承認を必須にする(→ 23 章)。
役割はポータルの D1 に持つ。Cloudflare Access は「ポータルに入れる社員かどうか」までしか見ない。役割の判定はポータル側の責任である。
ポータル https://apps.synon.co.jp/admin/new で 6 項目を入れて[作成]。
| 入力項目 | 例 | 用途 |
|---|---|---|
| アプリ名(日本語) | 見積書つくるくん | 一覧・通知の表示名 |
| slug(半角英数とハイフン) | mitsumori | リポジトリ名・ドメイン・DB 名の全部に使う |
| 担当者(つくる人) | 田中 | この人だけが zip を上げられる |
| 承認者 | 大山 | この人だけが本番公開できる |
| 使うもの | ☑ 表(D1) ☑ ファイル(R2) ☐ 早見表(KV) ☐ AI | 払い出す資源と wrangler.jsonc の中身が変わる |
| 見せる範囲 | 全社/営業部/指定メンバー | Access ポリシーになる |
変えるとドメイン・DB 名・リポジトリ名が全部ずれる。作成画面で「あとから変えられません」と赤字で出す。
[作成]を押してから、つくる人に zip が渡るまで 9 手順・所要 40〜90 秒。ポータルは Queue に積んで即座に「準備中」を返し、進捗を画面で更新する。
Worker が存在しない状態で Custom Domain を作ろうとすると失敗する。必ず 1 回デプロイが成功してからドメインを張る。
status = failed にして、作った資源は消さずに残す。同じ slug で作り直すと衝突するため、管理者画面に[この枠を撤去する]ボタンを別に用意し、そこから逆順で削除する。自動ロールバックは実装しない(消し間違いのほうが怖い)。
https://mitsumori-stg.apps.synon.co.jp を開くと、テンプレートの「準備できました」画面が出るポータルのアプリ詳細画面の[プロジェクトを受け取る]を押すと zip が落ちる。
ダウンロードされる zip は、GitHub の staging ブランチの現在の中身そのものである。だから 2 回目以降にダウンロードすれば、自分が前回上げたものが返ってくる。
mitsumori.zip
├── PROJECT.md ← ★まずこれを Cowork に読ませる(作り方の説明書)
├── app.config.json ← つくる人が触れる唯一の設定
├── .appmeta.json ← slug とリポジトリ名(触らない)
├── data/ ← ★データの置き場(→ 15 章)
│ ├── data.md 表の作り方の説明
│ ├── migrations/
│ │ └── 0001_init.sql 表の定義。ここに書いたものが配信先に作られる
│ └── seed/
│ └── .gitkeep 初期データ(CSV / SQL)を置くと初回だけ入る
├── src/
│ ├── client/ ← 画面(React)
│ │ ├── App.tsx main.tsx index.html styles.css
│ └── worker/ ← サーバ(API)
│ ├── index.ts
│ ├── lib/access.ts ログインした人が誰かを取り出す(触らない)
│ ├── lib/db.ts 表を読み書きする道具
│ └── routes/sample.ts お手本の API 1 本
├── docs/
│ └── SPEC.md ← /service-spec が書き込む仕様書
└── .claude/
├── skills/ ← Cowork のスキル 4 本
│ ├── service-spec/ 何を作るかを決める
│ ├── service-build/ 仕様書どおりに実装する
│ ├── service-pack/ アップロード用 zip をつくる
│ └── service-fix/ 配信が失敗したときに直す
└── CLAUDE.md ← Claude への恒久的な指示(触らない)
zip に入っていないもの:node_modules/、.git/、wrangler.jsonc、.github/、package-lock.json、実データ。理由は 15 章と 16 章で説明する。つくる人はこれらの存在を知らなくてよい。
PROJECT.md を読んで」と言う。以降はスキルが誘導する| スキル | いつ使う | 何をする |
|---|---|---|
/service-spec | 最初に 1 回 | 質問に答えていくと docs/SPEC.md ができる。「誰に何が見えてよいか」を最初に書かせるのが要点 |
/service-build | 実装するとき | docs/SPEC.md を読み、表 → API → 画面 の順に作る |
/service-pack | 上げる前 | 除外ルールに沿って zip を作る。構文チェックと禁止事項チェックもここで通す |
/service-fix | 配信が失敗したとき | ポータルからコピーしたエラー本文を貼ると、原因の行を直す |
| できるか | 補足 | |
|---|---|---|
| ファイルを読む・書く・検索する | ○ | 接続フォルダ上で普通に動く |
| Claude に実装させる | ○ | ここが本命 |
npm install / npm run dev | × | 接続フォルダのシェルはネットワーク非接続・1 回約 45 秒の上限。この基盤はそもそも必要としない |
| ブラウザで動きを見る | ×(手元では) | ④ で上げて ⑤ の試用環境で見る |
| ファイルを削除する | △ | 既定では不可。_to_delete/ に移す運用(Claude が案内する) |
「作ったのに動かして見られない」。ここで挫折させないために、PROJECT.md の冒頭に 「手元では動きません。上げてから見ます。1 回 2 分です」と大きく書く。1 週目の実測でここが最大のつまずきになるはずなので、早見表に必ず追記する。
/service-pack)$ /service-pack
✔ 構文チェック(TypeScript / SQL / JSON)… OK
✔ 禁止事項チェック … OK
✔ 保護パスは含めない … OK(4 ファイルを除外)
✔ サイズ … 312 KB / 上限 20 MB
✔ ファイル数 … 38 / 上限 500
できました: upload/mitsumori-20260828-1642.zip
ポータルの[新しい版を上げる]に、この zip を投げてください。
構文チェックをローカルでやるのがこのスキルの存在意義である。ビルドは Actions でしか回らないので、明らかな構文エラーを 2 分待ってから知るのは無駄が大きい。tsc は使えない(node_modules が無い)ので、Claude 自身に読ませて確認させるという素朴な方法を採る。完全ではないが、括弧の閉じ忘れレベルは拾える。
zip は全置換でコミットされる。/service-pack の除外ルールを間違えると ファイルが黙って消える。「削除 1」と出たときに「あれ?」と気づけるかどうかで事故の数が変わる。
data/ のファイルが削除される場合は、チェックを入れないと[上げる]が押せない画面は「配信中(1/3 コミット済み)」のように進む。約 2 分で「試用環境に出ました」になる。失敗したら赤くなり、Actions のログの末尾 60 行がそのまま画面に出る。ここをコピーして /service-fix に貼るのが直しかたの導線。
https://<slug>-stg.apps.synon.co.jp を開く。確かめることは 6 点(既存資料と同じ 6 点を使う)。
| # | 確かめること | これを飛ばすとどうなるか |
|---|---|---|
| 1 | 入れるか(Access のログインを通るか) | — |
| 2 | 作った画面が出るか | — |
| 3 | ブラウザを閉じて開き直しても、入れたデータが残っているか | 「画面の中だけで保持していて、リロードで消える」実装が本番まで届く |
| 4 | 変な値(空欄・全角数字・とても長い文字列)で壊れないか | 使い始めた初日に落ちる |
| 5 | スマホで開いて読めるか | — |
| 6 | 同僚 1 人に URL を渡して、説明なしで使えるか | 「作った本人しか使えないもの」が量産される |
3 と 6 は必須。この 2 つが、この基盤で最も多い 2 種類の失敗をそれぞれ潰す。
ポータルのアプリ詳細に、承認者にだけ[本番公開]ボタンが出る。押す前に見えるもの:
data/migrations/ の変更の有無(あれば黄色の警告)[本番公開]を押すと、ポータルが GitHub の staging を main にマージする(merge API)。マージは GitHub App のトークンで行うため、main への push を待つ Actions が起動する(→ 20 章 落とし穴 3)。約 2 分で本番に出る。
差分の確認はポータルの画面で終わっている。GitHub 上のレビューが必要な回(migrations の破壊的変更など)だけ、ポータルが PR を作ってエンジニアに投げる、という二段構えにする。
誰がいつ何を公開したかは D1 の deployments に残る。この記録があることが「非エンジニアが本番に触れる」ことの唯一の担保なので、削除できない設計にする(監査ログは追記のみ)。
| 状況 | やること |
|---|---|
| 試用環境で不具合を見つけた | ③ に戻って直し、また上げる。試用環境は何度上げてもよい(承認不要) |
| 本番に出したあとで不具合を見つけた | ポータルの[1 つ前に戻す]。前の版の tree でコミットを作り直して再配信する。約 2 分 |
| Cowork のプロジェクトを消してしまった | [プロジェクトを受け取る]で最新版を落とし直す。手元は写しなので何も失われない |
| 別の人が引き継ぐ | 管理者が担当者を付け替える。新担当者が zip を落とすところから始める |
| どの版に戻せるか知りたい | 配信履歴に全版が並ぶ(コミット SHA・日時・実行者・差分) |
「前の tree で新しいコミットを作る」方式にするのは、履歴を書き換えないため。git revert 相当の操作をポータルが行う。force push は一切しない。
使われなくなったアプリの畳みかた。「消す」を最後に置き、途中で止められるようにする。
| 段階 | 操作 | 元に戻せるか | |
|---|---|---|---|
| 1 | 休止 | ポータルで[休止]。Access のポリシーを「管理者のみ」に変更。一覧から消える | ○ ボタン 1 つ |
| 2 | データの書き出し | D1 の全テーブルを CSV に、R2 の全ファイルを zip にして保管領域へ | — |
| 3 | 資源の停止 | Custom Domain を外し、Worker を削除。D1 と R2 は残す | △ 再デプロイで戻る |
| 4 | アーカイブ | GitHub リポジトリを archive。D1・R2 は 1 年保持 | △ |
| 5 | 削除 | 1 年後に D1・R2・リポジトリを削除 | × |
「もう使わない」と言われたアプリの数字を、半年後に誰かが探しに来る。
| リポジトリ | 中身 | 誰が触る |
|---|---|---|
synon/apphub | ポータル本体(Worker + 管理画面 + Queue コンシューマ) | エンジニアのみ |
synon/apphub-template | テンプレートの正本。ここを直すと以後の新規アプリに反映される | エンジニアのみ |
synon/app-<slug> | アプリ 1 本につき 1 リポジトリ。プライベート | ポータルが自動で書く |
アプリを 1 リポジトリ 1 本にするのは、権限とロールバックを単純にするためである。モノレポにすると「A さんの変更で B さんのアプリが落ちる」が起き、差分プレビューも複雑になる。
テンプレートを直しても、既に作られたアプリには自動で反映されない — アプリハブでは反映できる。保護パス(16 章)はポータルが毎回テンプレートの現行版で上書きするので、.github/workflows/deploy.yml と .claude/ の改善は、次に誰かが zip を上げた瞬間に全アプリへ広がる。
逆に src/ の共通コードは広がらない。共通化したいものは保護パスに入れるか、npm パッケージにする。この判断を最初に決めておくこと。
「テンプレートではデータフォルダを外出しにして、配信先で初期作成される設計」を具体化する。この章がこの設計書で最も重要である。
zip を上げても本番のデータが消えないようにするため。これに尽きる。
zip は全置換でコミットされる。もし実データがリポジトリに入っていたら、つくる人が古い zip を上げた瞬間に本番のデータが 3 日前に巻き戻る。そんな仕組みは 1 回目の事故で使われなくなる。
3 段階で作られる。すべて冪等(何回やっても同じ結果)にするのが設計条件。
| いつ | 誰が | 何が作られる | 冪等性の担保 |
|---|---|---|---|
| 枠を作るとき | ポータル(Cloudflare API) | D1 データベース本体(空)、R2 バケット、KV 名前空間 | 既に同名があれば作らず ID を取得 |
| 毎回の配信 | Actions(wrangler d1 migrations apply) | 表・索引。未適用の migration だけが流れる | D1 の d1_migrations テーブル |
| 初回配信だけ | Actions(seed スクリプト) | 初期データ | _apphub_seeds にファイル名を記録し、記録済みは飛ばす |
-- テンプレートの data/migrations/0000_apphub.sql(保護パス。つくる人は触らない)
CREATE TABLE IF NOT EXISTS _apphub_seeds (
file TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
data/data.md の中身)data/migrations/ に新しいファイルを足す。既にあるファイルは直さない0002_やること.sql のように 4 桁の番号 + 内容。番号は前より大きくdata/seed/ に CSV を置く。初回の 1 回だけ入る「試しに本物の顧客リストを data/seed/ に置く」が最も起きやすい事故である。
/service-pack で data/seed/ の CSV が 100 行を超えたら警告を出す| 本番 | 試用 | |
|---|---|---|
| D1 | app-<slug>-db | app-<slug>-db-stg |
| R2 | app-<slug>-files | app-<slug>-files-stg |
| migrations | 適用する | 先に適用する(試用で失敗したら本番には行かない) |
| 中身 | 業務データ | seed だけ。本番のコピーは入れない |
「試用に本番のデータを入れてほしい」は必ず言われる。断る。どうしても必要なら、管理者が個人情報を落としたダンプを作って入れる運用にし、つくる人の操作ではできないようにする。
アップロードされた zip の中のファイルは、パスによって 3 通りに扱われる。
wrangler.jsonc を生成物にするのかwrangler.jsonc はこのアプリが触ってよい資源の一覧そのものだからである。ここに他人の D1 の ID を書けば、他人のデータが読める。つくる人(と Claude)に編集させてはいけない。
wrangler.jsonc を置かない(.gitignore に入れる)scripts/gen-wrangler.mjs が app.config.json + Actions の環境変数(資源 ID) から生成する app.config.json ─┐
├─→ scripts/gen-wrangler.mjs ─→ wrangler.jsonc ─→ wrangler deploy
Actions variables ┘ (allowlist で検証)
D1_ID / D1_ID_STG / R2_NAME / KV_ID / ACCESS_AUD …
生成に失敗したら配信は止まる。app.config.json に allowlist 外の値が書いてあったら、その場で落として理由を画面に出す。
ポータルがコミットを組み立てるときに、毎回。zip の中身を無視して synon/apphub-template の現行 main の該当ファイルを読み、それをコミットに含める。
apphub-template の main は必ず PR 経由・エンジニア 2 名承認にするapp.config.json 仕様用意する人つくる人(と Claude)が触れる唯一の設定ファイル。
{
// 表示名。ポータルの一覧とアプリのタイトルに出る
"name": "見積書つくるくん",
// 使うもの。false にすると wrangler.jsonc から丸ごと消える
"uses": {
"d1": true, // 表(データベース)
"r2": true, // ファイルの保管
"kv": false, // 早見表(キャッシュ)
"ai": false // Workers AI(従量課金なので既定 false)
},
// 定期実行。cron 式。最大 3 本
"cron": ["0 0 * * *"], // UTC 0:00 = 日本時間 9:00
// 追加で使う npm パッケージ。allowlist 内のみ
"packages": ["zod", "date-fns"],
// 見せる範囲。Access のポリシーになる。ポータル側でも検証する
"visibleTo": ["全社"],
// 配信結果の通知先(Chatwork のルーム ID)
"notify": { "chatwork": "123456789" },
// アップロード可能なファイルの上限(R2 を使う場合)
"upload": { "maxMb": 10, "allow": ["pdf", "png", "jpg", "xlsx", "csv"] }
}
依存に何が入るか誰も見ていない。許した瞬間に、この基盤の安全性の前提が全部崩れる。
初期の allowlist は次の 10 個だけにし、追加要望はエンジニアが 1 件ずつ判断する。
| パッケージ | 用途 | パッケージ | 用途 |
|---|---|---|---|
zod | 入力チェック | papaparse | CSV の読み書き |
date-fns | 日付の計算・書式 | xlsx | Excel の読み書き |
hono | ルーティング(テンプレートが使用) | marked | Markdown の表示 |
nanoid | ID の採番 | chart.js | グラフ |
clsx | クラス名の組み立て | @cloudflare/workers-types | 型定義 |
ポータルの検査と Actions の生成の両方が同じ表を見る。2 か所に持つと必ずずれるので、テンプレートリポジトリの allowlist.json を正本とし、ポータルはそれを読んで 5 分キャッシュする。
zip 1 本のコミットは ファイル数前後の GitHub API 呼び出し(blob 作成 → tree → commit → ref 更新)になる。40 ファイルで 43 回。ネットワーク待ちが支配的で、リクエストの中で全部やると利用者を 20〜40 秒待たせる。
max_batch_size: 1、max_retries: 3。冪等性は「同じ tree なら同じ commit になる」ことで担保(同一内容の再実行は空コミットになるのでスキップ)ポータル自体をビルドが要る構成にすると、ポータルを直すのが億劫になる。画面は 1 枚の HTML(フレームワーク無し・素の JS)にし、Static Assets で配る。1,500 行程度に収まる。
-- アプリ台帳
CREATE TABLE apps (
id TEXT PRIMARY KEY, -- ULID
slug TEXT NOT NULL UNIQUE, -- mitsumori
name TEXT NOT NULL,
status TEXT NOT NULL, -- provisioning|ready|failed|suspended|archived
owner_email TEXT NOT NULL, -- つくる人
approver_email TEXT NOT NULL, -- 承認者
repo_full TEXT, -- synon/app-mitsumori
repo_id INTEGER, -- GitHub の数値 ID(org secret 付与に使う)
uses_json TEXT NOT NULL,
visible_to TEXT NOT NULL,
cf_json TEXT, -- 払い出した資源の ID
prod_url TEXT, stg_url TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- アップロードされた zip 1 回分
CREATE TABLE uploads (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id),
uploader TEXT NOT NULL,
r2_key TEXT NOT NULL, -- uploads/<app>/<id>.zip
bytes INTEGER NOT NULL,
file_count INTEGER NOT NULL,
diff_json TEXT NOT NULL, -- {added:[],changed:[],deleted:[]}
verdict TEXT NOT NULL, -- pending|ok|hold|accepted|rejected
reject_reason TEXT,
commit_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 配信 1 回分(staging も本番も)
CREATE TABLE deployments (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id),
env TEXT NOT NULL, -- staging|production
commit_sha TEXT NOT NULL,
tree_sha TEXT NOT NULL, -- ★ロールバック用
run_id INTEGER,
status TEXT NOT NULL, -- queued|building|success|failed
log_tail TEXT, -- 失敗時の末尾 60 行
actor TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
-- 監査ログ(追記のみ・削除する API を作らない)
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL DEFAULT (datetime('now')),
actor TEXT NOT NULL,
action TEXT NOT NULL, -- app.create / upload / promote / rollback / suspend …
app_id TEXT,
detail TEXT
);
「あの版に戻す」は、その tree_sha を、親 = 現在の HEAD として新しいコミットを作るだけで済む。履歴を書き換えない。
| 種別 | 権限 | 何に使うか |
|---|---|---|
| Organization | Administration: Write | リポジトリの作成 |
| Organization | Secrets: Write | org secret の対象リポジトリに追加 |
| Repository | Contents: Write | blob/tree/commit/ref。ブランチ作成・マージ |
| Repository | Workflows: Write | ★ .github/workflows/** を含む push に必須 |
| Repository | Actions: Read & Write | workflow_dispatch・run の状態取得・ログ取得 |
| Repository | Variables: Write | 資源 ID をリポジトリ変数に入れる |
| Repository | Metadata: Read | 既定(必須) |
| Repository | Pull requests: Write | 破壊的変更のときだけ PR を作る |
Webhook は workflow_run と push を購読し、/api/webhooks/github で受ける(署名検証必須)。
POST /repos/{owner}/{repo}/generate は、公式ドキュメント上は installation token に対応していると読めるが、「Resource not accessible by integration」で失敗する報告が多数ある(GitHub Community Discussion #52316)。
対策 = 使わない。空リポジトリを POST /orgs/{org}/repos で作り、テンプレートの中身を Git Data API で初回コミットする。どのみち zip アップロードで同じコードパスを使うので、実装は増えない。
Contents: Write だけでは refusing to allow a GitHub App to create or update workflow で拒否される。Workflows: Write が必要。テンプレートの初回コミットにも保護パスの上書きにも deploy.yml が含まれるので、これが無いと 100% 落ちる。
GitHub Actions 内の GITHUB_TOKEN で push しても、次のワークフローは起動しない(公式ドキュメント明記)。GitHub App の installation token による push は起動する。
アプリハブはポータルから App トークンで push するので問題ないが、「Actions の中から Actions を起こす」実装に逃げないこと。将来 deploy.yml の中で自動コミットを足したくなったときにここで詰まる。
1. GET /repos/{o}/{r}/git/ref/heads/staging → 現在の commit sha
2. GET /repos/{o}/{r}/git/commits/{sha} → 現在の tree sha
3. GET /repos/{o}/{r}/git/trees/{tree}?recursive=1 → 現在の全ファイル(差分表示用)
4. 採用するファイルごとに POST …/git/blobs → blob sha(base64 で送る)
※ 中身が変わっていないファイルは 3 で得た blob sha を再利用し、POST しない
5. POST …/git/trees base_tree を付けない ← ★全置換の要
tree: [{path, mode:"100644", type:"blob", sha}, …]
6. POST …/git/commits parents:[現在の sha] tree:新 tree
7. PATCH …/git/refs/heads/staging sha:新 commit force:false
base_tree を付けないことが「丸ごと置換」の実装そのもの。付けると差分マージになり、消したはずのファイルが残るforce:false。他の経路で staging が進んでいたら 422 で落ちる。そのときは「他の人が先に上げました。最新を受け取り直してください」と出す。force push は絶対に実装しないdeploy.yml の設計用意する人 push (staging) ──┐
push (main) ──┤
workflow_dispatch──┴─→ 1. 環境を決める(ref=main → production / それ以外 staging)
2. app.config.json を検査(allowlist)
3. package.json と wrangler.jsonc を生成
4. npm install
5. 構文チェック(tsc --noEmit) ★
6. ビルド(vite build)
7. D1 migrations を適用 ★
8. seed を適用(未適用のものだけ)
9. wrangler deploy
10. 疎通確認(/healthz が 200 か) ★
11. 結果をポータルに POST + Chatwork
/service-fix が読むのはこの本文wrangler d1 migrations apply --remote。本番では 9 の前に必ず流す。逆順にすると「新しいコードが古い表を触る」瞬間ができる/healthz を叩いて 200 でなければ 失敗として扱い、直前の版に自動で戻す| 落ちた場所 | 扱い | 利用者に出す文言 |
|---|---|---|
| 2(設定の検査) | 配信しない | 「app.config.json の◯◯が使えません」+ 許可されている値の一覧 |
| 5(構文チェック) | 配信しない | エラー本文の末尾 60 行そのまま +「/service-fix に貼ってください」 |
| 7(migrations) | 配信しない。表は途中まで変わっている可能性がある | 「表の変更に失敗しました。エンジニアに連絡してください」+ 管理者に通知 |
| 9〜10(配信・疎通) | 自動で 1 つ前に戻す | 「配信しましたが起動しなかったため、前の版に戻しました」 |
DB の巻き戻しは失敗すると被害が大きい。人が見る。
| 置き場 | 中身 | 誰が入れる |
|---|---|---|
org secret CF_API_TOKEN | Cloudflare の API トークン(Workers/D1/R2/KV の Edit) | エンジニアが 1 回。対象リポジトリはポータルが自動で足す |
org secret APPHUB_WEBHOOK_SECRET | 結果を POST し返すときの署名鍵 | 同上 |
repo variable D1_ID ほか | 資源の ID。秘密ではない | ポータルが枠作成時に入れる |
| アプリ固有の秘密(外部 API キー) | リポジトリに入れない。Cloudflare Secrets Store に置き、ポータルの画面から登録する | 管理者 |
アプリ固有の秘密をつくる人に扱わせないのは原則 2 のとおり。「Chatwork に通知したい」のような要望は、テンプレート側に共通の通知関数を用意して、鍵はプラットフォーム側が持つ形にする。
CF_API_TOKEN で。ポータルから叩く)| 資源 | エンドポイント |
|---|---|
| D1 | POST /accounts/{acct}/d1/database {name} |
| R2 | POST /accounts/{acct}/r2/buckets {name} |
| KV | POST /accounts/{acct}/storage/kv/namespaces {title} |
| Custom Domain | PUT /accounts/{acct}/workers/domains {zone_id, hostname, service, environment} |
| Access アプリ | POST /accounts/{acct}/access/apps {name, domain, type:"self_hosted"} |
DNS レコードは Custom Domain の作成時に Cloudflare が自動で作る。手で *.apps.synon.co.jp のワイルドカードを張る必要はなく、張ると Access の対象がぼやけるので張らないほうがよい。
公式ドキュメントに 「Static Assets を有効にした Worker には、ルータが ctx.access を渡さない」と明記されている。テンプレートは Static Assets を使うので、ctx.access は当てにできない。
対策として、2 層目をホスト名ベースの Access アプリにし、アプリ側は Cf-Access-Jwt-Assertion ヘッダを自前で検証する(cf-internal-app-starter の access.ts が既にこの実装)。
ただしヘッダが確実に付くかは実測が必要(→ 28 章 未確認 1)。ここが崩れると設計の前提が変わるので、フェーズ 0 の最初の 1 時間で潰す。
app.config.json の visibleTo と Access ポリシーは、ポータルが同期する。つくる人が visibleTo を変えて zip を上げたら、ポータルはその場では反映せず、承認者に確認を出す。「見せる範囲を広げる」は本番公開と同じ重さの操作である。
アップロードから差分プレビューまでのあいだに、次の検査を順に通す。1 つでも落ちたら受け付けない。
| # | 検査 | 上限・条件 | 落ちたときの文言 |
|---|---|---|---|
| 1 | 認証 | Access の JWT が有効 | (Access が弾く) |
| 2 | 権限 | このアプリの担当者本人 or 管理者 | 「このアプリの担当ではありません」 |
| 3 | アプリの状態 | status = ready | 「準備中です。少し待ってください」 |
| 4 | サイズ | 20 MB 以下 | 「大きすぎます。node_modules が入っていませんか」 |
| 5 | 形式 | zip として展開できる | 「zip として読めません」 |
| # | 検査 | 条件 | 意図 |
|---|---|---|---|
| 6 | ファイル数 | 500 以下 | 暴走の検出 |
| 7 | 展開後の合計 | 60 MB 以下 | zip 爆弾 |
| 8 | 圧縮率 | 200 倍以下 | zip 爆弾 |
| 9 | パス | .. を含まない/絶対パスでない/\ を含まない | パストラバーサル |
| 10 | パス | UTF-8 として妥当・255 文字以下・制御文字なし | 破損 zip |
| 11 | 種別 | シンボリックリンク・特殊ファイルを含まない | 逃走 |
| 12 | 拡張子 | allowlist(ts tsx js jsx json css html svg png jpg md sql csv txt …) | 実行ファイルの混入 |
| 13 | 除外 | node_modules/ .git/ dist/ .data/ upload/ を含まない | 事故の予防(含んでいたら黙って捨てる) |
| 14 | 単一ファイル | 2 MB 以下(画像は 1 MB) | 素材の置きすぎ |
| 15 | 必須 | app.config.json と src/worker/index.ts がある | 中身の取り違え |
| 16 | app.config.json | JSON として妥当・スキーマに合う・packages が allowlist 内 | 17 章 |
| 17 | .appmeta.json | zip の slug と URL の slug が一致 | ★ 別のアプリの zip を上げた事故。これが一番よく起きる |
| 18 | seed | data/seed/** の行数が 100 行以下 | 本番データの混入 |
| 19 | seed | メールアドレス・11 桁の数字列・氏名らしき列を検出したら 保留 | 個人情報の混入 |
| 20 | migrations | 既存ファイルが変更されていない(新規追加のみ) | 適用済み migration の書き換えは D1 で不整合になる |
| 21 | migrations | DROP TABLE / DROP COLUMN / DELETE FROM / TRUNCATE の検出 | 含むならエンジニア承認を必須にフラグ |
| 22 | 差分 | 削除が 10 件超、または data/ の削除があれば 確認チェック必須 | 全消し事故 |
19(個人情報の疑い)と 21(破壊的 DDL)は拒否しない。拒否すると、つくる人は回避方法を探し始める(ファイルを分割する等)。「管理者に回りました。連絡します」と伝えて人の判断に上げるほうが安全であり、実際に必要な変更なら通る。
| もの | 規則 | 例(slug = mitsumori) |
|---|---|---|
| GitHub リポジトリ | app-<slug> | synon/app-mitsumori |
| Worker(本番 / 試用) | app-<slug> / -stg | app-mitsumori |
| ドメイン(本番) | <slug>.apps.synon.co.jp | mitsumori.apps.synon.co.jp |
| ドメイン(試用) | <slug>-stg.apps.synon.co.jp | mitsumori-stg.apps.synon.co.jp |
| D1 | app-<slug>-db / -db-stg | app-mitsumori-db |
| R2 | app-<slug>-files / -files-stg | app-mitsumori-files |
slug の規則: 半角小文字英数とハイフン、3〜20 文字、数字始まり不可、予約語(api admin www apps portal new stg)不可。
| 項目 | 値 | 理由 |
|---|---|---|
| アプリ本数(全社) | 初期 30 本 | D1 は Paid で 50,000 個なので技術上の制約ではない。面倒を見きれる本数の上限 |
| 1 人が担当できる本数 | 5 本 | 担当者不在のアプリを増やさない |
| zip サイズ / ファイル数 | 20 MB / 500 | 23 章 |
| D1 の容量/アプリ | 10 GB | Paid の上限。超える設計はこの基盤の対象外 |
| R2 の容量/アプリ | 5 GB | ポータルが監視・超えたら通知。費用の暴走防止 |
| 1 日のアップロード回数/人 | 30 回 | 暴走の検出。超えたら通知だけ(止めない) |
上限が無いと、半年後に「誰も直せない 60 本」になる。上限に当たったら、新規を作る前に棚卸しで廃止する(工程 8)。
| 項目 | 月額 | 備考 |
|---|---|---|
| Workers Paid | $5 | アカウント単位。アプリ本数によらない。必須 |
| GitHub Team | $8 | $4 × エンジニア 2 名。つくる人はアカウント不要=課金対象外 |
| Cloudflare Access | $0 | 現行の料金ページに社内利用の追加課金の記載なし(→ 28 章 未確認 5) |
| D1 / R2 / Queue | $0〜 | Paid に含まれる枠で社内 30 本なら届かない。R2 は 10 GB まで無料・egress 無料 |
| 合計 | $13 | アプリ本数によらない |
| Free の制約 | どこで詰まるか |
|---|---|
| CPU 10 ms/リクエスト | zip の展開・JWT 検証・D1 アクセスで確実に超える |
| D1 10 個/アカウント | 1 アプリで 2 個使うので 5 本で枯渇 |
| D1 容量 500 MB/DB、合計 5 GB | 台帳 1 本でも窮屈 |
| サブリクエスト 50 回/リクエスト | 全置換コミットは 40 ファイルで 43 回。足りない |
| Queues が使えない | 非同期処理が組めない |
Paid の $5 は「アプリが増えても増えない固定費」として説明するのが通りやすい。
ai を true にしたアプリが増えると従量課金になる。既定 false、有効化は管理者承認にしてある想定される失敗を、起きる頻度の高い順に並べた。1 週目に必ず出るものから書いてある。
| # | 症状 | 原因 | 復旧 | 予防 |
|---|---|---|---|---|
| 1 | 上げたのに「削除 12 件」と出る | /service-pack が除外しすぎ/フォルダの取り違え | 上げない。zip を作り直す | 差分プレビュー |
| 2 | 別のアプリの zip を上げた | Cowork のプロジェクトを取り違えた | 検査 17 で自動的に弾かれる | .appmeta.json |
| 3 | 構文エラーで配信が失敗 | 型が合わない | エラー本文を /service-fix に貼る | /service-pack の事前チェック |
| 4 | 試用では動くのに本番で動かない | 本番の D1 に表が無い | 本番の Actions ログを見る。migrations の順序を確認 | 本番でも必ず migrations を先に流す |
| 5 | 「他の人が先に上げました」 | 同じアプリを 2 人が触った | 最新を受け取り直して作業をやり直す | 担当者を 1 人に限る |
| 6 | 配信は成功したが画面が真っ白 | ビルドは通ったが実行時エラー | /healthz で検出 → 自動で前の版に戻る | 21 章 手順 10 |
| 7 | 表を消す SQL を書いてしまった | 「作り直したい」と Claude に言った結果 | 検査 21 で保留 → エンジニア承認 | data/data.md のルール 3 |
| 8 | 枠作成が途中で止まった | Cloudflare API の一時失敗 | status=failed。管理者が[撤去]してから作り直す | 自動ロールバックはしない |
| 9 | 本番のデータが消えた | migrations の破壊的変更が本番に流れた | D1 の Time Travel で 30 日以内に復元 | 検査 21 + エンジニア承認 |
| 10 | 全アプリの配信が同時に失敗 | テンプレートの deploy.yml を壊した | apphub-template を revert。保護パスは次の配信で自動的に戻る | テンプレートの main は PR + 2 名承認 |
| 失ったもの | どこから戻すか | 保持期間 |
|---|---|---|
| コード | GitHub(全コミット) | 無期限 |
| 上げた zip の原本 | R2 ARTIFACTS | 90 日 |
| 業務データ(D1) | D1 Time Travel | 30 日 |
| ファイル(R2) | 世代管理なし。アプリ側で上書き削除しない設計にする | — |
| 誰が何をしたか | D1 audit | 無期限(追記のみ) |
| 配信ログ | GitHub Actions / 失敗時の末尾は D1 | 90 日 / 無期限 |
「同じ名前で上書きしたら前のファイルが消える」を防ぐため、テンプレートのファイル保存関数は必ずキーに ULID を付ける(保護パスにして触らせない)。
全部を作ってから使わせない。各フェーズの終わりに「ここで止めても損しない」状態を作る。
Cf-Access-Jwt-Assertion ヘッダが付くか(→ 28 章 未確認 1)POST /orgs/synon/repos が通るか.github/workflows/deploy.yml を含むコミットが push できるか(Workflows 権限)PUT /orgs/synon/actions/secrets/CF_API_TOKEN/repositories/{id} が通るかこの 7 項目を潰すのに、たぶん半日ではなく 1 日かかる。見積もりに入れておくこと。
ポータルを作らない。エンジニアが手で全部やる。
apphub-template を cf-internal-app-starter から作る(data/ の外出しと wrangler.jsonc 生成を入れる)app-sample を手で作り、手で資源を払い出し、手でコミットし、deploy.yml を通すここで deploy.yml が完成する。これがフェーズ 1 の成果物。ポータルは deploy.yml を起動するだけの存在なので、先にこちらを固める。
apps テーブルは手で入れるこの時点で 「社内 Web サービスの一覧が見られるポータル」としては既に価値がある。
/service-pack スキル「つくる人が自分で試用環境に出せる」状態。フェーズ 4 を待たなくてよい(本番公開だけエンジニアが手で GitHub でマージする)。
| 時期 | やること |
|---|---|
| 1 週目 | フェーズ 0〜1 |
| 2〜3 週目 | フェーズ 2〜4 |
| 4 週目 | エンジニア自身が 1 本作って通す(つくる人の立場で。ここで PROJECT.md とスキルの文言が固まる) |
| 5 週目 | 意欲の高い 1 名に作らせて横で見る |
| 6〜7 週目 | 同じ 1 名が本番公開まで |
| 8 週目以降 | 部署展開 |
| # | 未確認 | 崩れたときの影響 | 代替案 |
|---|---|---|---|
| 1 | Static Assets を使う Worker に Cf-Access-Jwt-Assertion が付くか。公式は「ctx.access は渡らない」とだけ書いており、ヘッダの有無は未記載 | アプリ内で「誰が使っているか」が分からなくなる。個人別の台帳が作れない | ① Static Assets を使わず Worker から HTML を返す ② 画面と API を別 Worker に分ける ③ ctx.access が使える構成に戻す |
| 2 | installation token でのリポジトリ作成が Organization Administration 権限で通るか | 枠の自動作成ができない | エンジニアが手でリポジトリを作り、ポータルは既存リポジトリに紐づけるだけにする(手間 1 分/本) |
| 3 | generate(テンプレートから生成)の可否 — 使わない設計にしてある | 影響なし | — |
| 4 | Workers のリクエストボディ上限がゾーンプランで決まるという記述の実挙動 | 20 MB 上限には影響しない(Free でも 100 MB) | R2 への直接アップロード(presigned URL)に切り替え |
| 5 | Cloudflare Access の課金。現行の料金ページに社内利用の追加課金の記載が見当たらない | 人数課金だと費用の前提が変わる | 既存資料(cloudflare_os_access_control)の訂正事項と整合を取る |
| 6 | Cowork の接続フォルダで zip を作れるか(/service-pack の実装手段) | スキルが成立しない | Cowork のクラウド側で zip を作って会話に添付し、そこからアップロードする |
| 7 | D1 の Time Travel が migrations 適用後の状態から戻せるかの実挙動 | 失敗モード 9 の復旧手段が無くなる | 配信前に自動でエクスポート(wrangler d1 export)を R2 に取る手順を deploy.yml に追加 |
PROJECT.md とスキルの文言を作り直して、もう 1 名で再試行/service-pack の事前チェックが弱い。ここに投資するか、フェーズ 3 の輪を止める既存資料の判定 5 問はそのまま使える。アプリハブはプラン B の置き換えなので、「プラン B」と書いてあるところを読み替える。
synon-apphub-kit.zip)の中身用意する人synon-apphub-kit/
├── README.md 全体の説明と読む順番
├── APPLY.md ★導入手順(この順にやれば動く)
├── portal/ ポータル本体
│ ├── package.json wrangler.jsonc tsconfig.json
│ ├── schema/0001_init.sql 19 章の DDL
│ ├── src/index.ts ルーティング
│ ├── src/types.ts
│ ├── src/lib/access.ts Cf-Access-Jwt-Assertion の検証
│ ├── src/lib/github.ts ★App JWT / installation token / 全置換コミット
│ ├── src/lib/cf.ts D1・R2・KV・Custom Domain・Access の払い出し
│ ├── src/lib/zip.ts ★展開と 22 項目の検査
│ ├── src/lib/util.ts ULID・監査ログ・Chatwork・HMAC
│ ├── src/routes/apps.ts 一覧・作成・詳細・ダウンロード
│ ├── src/routes/uploads.ts アップロード・差分・確定
│ ├── src/routes/deploy.ts 本番公開・ロールバック
│ ├── src/routes/webhooks.ts Actions からの結果受け
│ ├── src/queue/consumer.ts ★重い処理の本体
│ └── public/index.html 管理画面(単一 HTML)
├── template/ テンプレートに足す差分
│ ├── app.config.json allowlist.json PROJECT.md
│ ├── data/data.md つくる人向けの説明
│ ├── data/migrations/0000_apphub.sql
│ ├── scripts/gen-wrangler.mjs ★app.config.json → wrangler.jsonc
│ ├── scripts/apply-seed.mjs 初期データを 1 回だけ入れる
│ ├── scripts/report.mjs 配信結果をポータルに返す
│ └── .github/workflows/deploy.yml
├── skills/ Cowork のスキル 4 本
│ ├── service-spec/SKILL.md service-build/SKILL.md
│ └── service-pack/SKILL.md service-fix/SKILL.md
└── docs/OPERATIONS.md 運用手順(枠の撤去・秘密の登録・棚卸し)
ポータルの TypeScript は tsc --noEmit が通っている。zip 検査は 9 通りのケース(正常/別アプリ/allowlist 外/破壊的 DDL/個人情報/拡張子/パストラバーサル/必須欠落/差分計算)で動作を確認済み。deploy.yml は YAML として妥当、SQL は SQLite で実行確認済み。実機での結線(GitHub API・Cloudflare API)は未検証なので、フェーズ 0 で潰すこと。
ctx.access が渡らない)同じフォルダ /Users/synon/Documents/Synon/プロジェクト/CloudFlare/:
| 資料 | 本書との関係 |
|---|---|
claude-code-internal-app-workflow-planb.{md,html} | プラン B 提案書。本書はこれの配信経路を置き換えたもの |
cloudflare-os-internal-service-guide.{md,html,pptx} | Cloudflare OS の判断資料。併用の相手 |
cloudflare-stack-internal-service-stepbystep.{md,html,pptx} | 作業手順版。工程の分け方を踏襲 |
cf-internal-app-starter/ | テンプレートの元。apphub-template はここから作る |