はじめに
自己ホスト型の請求書管理 OSS NeNe Invoice を、エンジニアではない人(想定は税理士さん)に見てもらう機会ができました。そこで欲しくなるのが「URL を踏むだけで、ログインも登録もなしに触れるデモ」です。
ただ、動くデモ環境を公開で置くのは、やってみると意外と論点が多いです。
- 共有デモは荒れる。 全員が同じアカウントに入ると、前の人が入れたテストデータが次の人に見えます。データを消して回る運用が発生します。
- 「初期状態に戻す」が必要になる。 定期リセットの cron を書くと、今度は「触っている最中にリセットされた」が起きます。
- アカウント発行は面倒すぎる。 デモのためにサインアップさせたら、その時点で離脱します。
NeNe Invoice はマルチテナント(組織=org 単位でデータ分離)対応を先に済ませていたので、これを利用して「アクセスごとに使い捨ての org を払い出す」方式でデモを実装しました。この記事はその実装記録です。
-
GET /demo/{template}を踏むと、その人専用のテナントが生成される - 業種別のリアルなシードデータ入りで、ダッシュボードに 302 される
- 使い終わった org は cron が黙って掃除する
- 認証コアは無改変。共有レンタルサーバー + SQLite でも動く
説明より先に、実物を触ってもらうのが早いです。
- 建設・工務店: https://invoice.ayane.co.jp/demo/kensetsu
- ビルメンテ・清掃(定期請求): https://invoice.ayane.co.jp/demo/bldmainte
- 制作・コンサル(源泉徴収): https://invoice.ayane.co.jp/demo/seisaku
踏むたびに新品の使い捨て組織が立ち上がり、ダッシュボードに着地します(3時間で自動削除)。リロードするとログイン画面に戻りますが、それも仕様です — URL を踏み直せば、また新品が出てきます。理由は本文で説明します。
方式の比較
| 方式 | 実装 | 問題 |
|---|---|---|
| A. 共有デモアカウント + 定期リセット | 一番簡単 | 他人のデータが見える。リセットのタイミング問題 |
| B. 読み取り専用モード | データは汚れない | 「請求書を作る」体験そのものが見せられない |
| C. 使い捨てテナント | マルチテナント基盤が前提 | 生成コスト・掃除の仕組みが必要 |
見せたいのが「請求書を発行し、入金を消し込む」という書き込みを伴う体験だったので B は却下。マルチテナントの分離が既にあるなら、C の追加実装は薄い糊で済みます。実際、今回追加した本体は 5 クラス + 掃除スクリプトで計 900 行弱でした。
全体の流れ
ポイントは、デモ専用の認証をいっさい発明していないことです。「org を作る」「初期管理者を作る」「refresh token を発行する」はすべて本番のユースケースをそのまま呼び、デモハンドラはそれらを一列に並べて 302 を返すだけの糊に徹しています。
実装のポイント
1. 入口は環境変数でゲートする
/demo/* は org なしで踏める公開ルートなので、デモ用インスタンス以外では存在ごと隠します。
if ($this->env('DEMO_MODE', '0') !== '1') {
return $this->problemDetails->create($request, 'not-found',
'Not Found', 404, 'Demo mode is not enabled on this instance.');
}
403 ではなく 404 にしているのは、本番インスタンスに「デモ機能はあるが無効」という情報すら出さないためです。
2. org はランダム slug + 衝突リトライで作る
for ($attempt = 0; $attempt < self::SLUG_ATTEMPTS; $attempt++) {
$slug = 'demo-' . bin2hex(random_bytes(4)); // 例: demo-3f9a1c02
try {
return $this->createOrganization->execute(null, new CreateOrganizationInput(
name: $this->companyName($template),
slug: $slug,
plan: 'free',
adminEmail: 'admin@' . $slug . '.demo.local',
adminPassword: bin2hex(random_bytes(16)),
));
} catch (OrganizationSlugConflictException $e) {
continue; // 衝突したら引き直す(5回まで)
}
}
demo- という slug prefix が「使い捨て」のマーカーを兼ねます。後述の掃除スクリプトはこの prefix だけを見て対象を選ぶので、本番 org に触れる事故が構造的に起きません。管理者パスワードはランダム生成して誰にも渡しません — このデモにログイン画面から入る経路は最初から存在しない、が正解の設計です。
3. シードデータは「業種テンプレ × T相対日付」
デモの説得力はシードデータで決まります。空のダッシュボードを見せても何も伝わらないので、業種ごとに現実的なデータを用意しました。
-
kensetsu(建設・工務店)/bldmainte(ビルメンテ・清掃)/seisaku(制作・コンサル)の3テンプレート - 日付はすべて今日(T)からの相対で生成。「今月発行した請求書が数枚、期限超過が1枚、今月入金済みが数枚」という状態が、いつアクセスしても崩れない
- 適格請求書の登録番号は自社と主要取引先に、振込名義が請求先と一致しない入金の消込ケースは各テンプレートに1件ずつ仕込む。源泉徴収(負の明細行)は制作・コンサル(seisaku)テンプレートに入れた
名義ズレの消込は地味ですが効きます。「合同会社ブルームテック宛の請求に『ブル-ムテツク(ド』の振込が来る」— 半角カナで、しかも銀行の文字数制限で末尾が切れている。実務の人が一番「あるある」と感じる場面を、初期状態から見せられます。
4. セッションの受け渡しは cookie の Path スコープで
一番設計を迷ったのがここです。org を作った直後、ログイン画面を経由せずにその org のセッションへ入れる必要があります。
やったことは「ログイン成功時と同じ refresh + CSRF cookie を発行する」だけ。ただし cookie の Path をテナント slug でスコープします。
$slugBase = $installBase . '/' . $slug; // 例: /demo-3f9a1c02
return $this->responseFactory->createResponse(302)
->withHeader('Location', $slugBase . '/dashboard')
->withHeader('Cache-Control', 'no-store')
->withAddedHeader('Set-Cookie', SessionCookies::setRefresh($raw, $exp, $slugBase))
->withAddedHeader('Set-Cookie', SessionCookies::setCsrf($csrf, $exp, $slugBase));
ブラウザは 302 先の SPA を開き、SPA は初回ロードの silent refresh(POST /{slug}/auth/refresh)で access token を取得します。トークンの回転ロジックも cookie の中身も本番と同一で、デモ用の分岐は認証コアに1行もありません。
実際のレスポンスを見るとこうなっています(curl -sI https://invoice.ayane.co.jp/demo/kensetsu の抜粋)。
HTTP/2 302
cache-control: no-store
set-cookie: ni_refresh=...; Path=/demo-5b067975/auth; Secure; SameSite=Strict; HttpOnly
set-cookie: ni_csrf=...; Path=/demo-5b067975/; Secure; SameSite=Strict
location: /demo-5b067975/dashboard
払い出されたばかりの slug(demo-5b067975)に cookie の Path が閉じているのが見えます。隣で誰かが別の demo org を触っていても、cookie は互いのテナントに送られません。
割り切ったのは「リロードするとログイン画面に落ちる」こと(one-shot)。根因は、トークン回転後の refresh cookie がインストールベースのパスで再発行されるという path モードの既知制約で、直すには認証コア側の改修が要ります。そこでデモの割り切りとして、「もう一度 /demo/{template} を踏めば新品の org が出る」こと自体を『初期状態に戻す』ボタンとして扱うことにしました。使い捨て方式だと、リセット機能は作るものではなく最初から付いてくるものになります。
5. 掃除は cron で「雑に」やる
使い方: php tools/sweep-demo.php (cron 例: 毎時 0 分)
- slug prefix `demo-` の org のうち、作成から DEMO_TTL_HOURS(既定 3)超過を削除
- さらに DEMO_MAX_ORGS(既定 200)を超えた分は古い順に削除(暴走・DoS 保険)
TTL だけだと「cron が止まっている間に大量アクセスされる」に弱いので、件数の上限を別レイヤーで持ちます。誰かが /demo/kensetsu を1万回叩いても、残るのは新しい200 org だけです。
org 本体の削除は監査ログつきの DeleteOrganizationUseCase を通しますが、子テーブル(請求書・明細・入金…)は organization_id で一括 DELETE します。テーブルが存在しない軽量構成でも PDOException を握って続行します。普段なら行儀が悪い書き方ですが、「消えていいデータしか入っていない」ことが保証されているのが使い捨て方式の強みで、掃除側の実装をここまで雑に倒せます。
ハマりどころ
SQLite の「database is locked」。 シーダーを素直に書くと2本目の PDO 接続を開きたくなりますが、SQLite では リクエスト処理中の接続とぶつかってロックします。シーダーはアプリ本体と同じ共有クエリ実行器(1リクエスト1接続)を通すようにしました。MySQL 前提なら踏まない罠ですが、共有レンタルサーバーで SQLite 運用をするなら接続は増やさないが原則です。なお、冒頭の公開デモは同時アクセス時のロック回避のため MySQL で運用しています。
cookie の Path 設計。 サブディレクトリ設置(/invoice 配下など)に対応するため、Path は「インストールベース + slug」の合成です。ここを document root 決め打ちにすると、レンタルサーバーのサブディレクトリ配置で silent refresh だけが失敗する、というデバッグしにくい壊れ方をします。
まとめ
| ガードレール | 実装 |
|---|---|
| 本番での無効化 |
DEMO_MODE=1 ゲート(無効時は 404) |
| 本番 org との隔離 | slug prefix demo- がマーカー、掃除はこれしか見ない |
| ログイン経路の遮断 | 管理者パスワードはランダム生成・非開示 |
| データの寿命 | TTL 3時間(cron 掃除) |
| 暴走・DoS 保険 | org 件数上限(超過分は古い順に削除) |
| リセット機能 | 作らない — 再アクセス=新品の org |
マルチテナントの分離が先にあると、「試せるデモ」は薄い糊で手に入ります。逆に言うと、この方式の実装コストのほとんどは平時のテナント分離・ユースケース分割への投資で、デモ機能はその配当でした。
冒頭のデモ URL は共有レンタルサーバー上で、この記事に書いたガードレールそのままで動いています。壊れる心配なく触ってみてください — あなたが何をしても、3時間後にはこの世から消えます。
NeNe Invoice は MIT ライセンスの OSS です。実装の全体は GitHub の src/Demo/ と tools/sweep-demo.php にあります。
── 筆者: 森 秀之(彩音インターナショナル)— 自己ホストの業務ツール群を実運用中。
中小企業向けの業務システムを料金公開・固定価格で開発しています。
🔗 ayane.co.jp